refactor: 混合式架構 (Hybrid Approach) 的設定載入訊息設計。

-前端模擬動態流 (Simulated Streaming)
   -真實動態流 (True Streaming)
This commit is contained in:
swpa 2026-06-03 17:24:08 +08:00
parent ecedee49d2
commit 3cd7ce253e
4 changed files with 456 additions and 150 deletions

View File

@ -2454,7 +2454,7 @@ function resetAllManagerData() {
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config) // 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
// ============================================================================ // ============================================================================
// 執行綜合查詢 (CM / RPD) // 執行綜合查詢 (CM / RPD) - 🌟 導入前端模擬動態訊息流
async function executeQuery(mode) { async function executeQuery(mode) {
if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕"); if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕");
@ -2467,10 +2467,28 @@ async function executeQuery(mode) {
openModal(connInfo.host); openModal(connInfo.host);
const outputEl = document.getElementById('modalOutput'); const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">正在向 ${connInfo.host} 查詢中,請稍候...</span>`;
// 🌟 模擬動態訊息流邏輯
const states = [
`🔌 正在建立 SSH 連線至 ${connInfo.host}...`,
`📥 正在向設備發送查詢指令...`,
`🧩 正在等待設備回傳資料...`
];
let progressIdx = 0;
outputEl.innerHTML = `<span id="query-progress-text" style="color: #f39c12; font-weight: bold;">${states[0]}</span>`;
const progressInterval = setInterval(() => {
progressIdx++;
if (progressIdx < states.length) {
const textEl = document.getElementById('query-progress-text');
if (textEl) textEl.innerText = states[progressIdx];
}
}, 1500); // 每 1.5 秒切換一次狀態
try { try {
const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass); const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
clearInterval(progressInterval); // 收到結果,停止輪播
if (result.status === 'success') { if (result.status === 'success') {
outputEl.style.color = "#e0e0e0"; outputEl.style.color = "#e0e0e0";
outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data; outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data;
@ -2479,6 +2497,7 @@ async function executeQuery(mode) {
outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`; outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
} }
} catch (error) { } catch (error) {
clearInterval(progressInterval); // 發生錯誤,停止輪播
outputEl.style.color = "#e74c3c"; outputEl.style.color = "#e74c3c";
outputEl.textContent = `連線失敗: ${error}`; outputEl.textContent = `連線失敗: ${error}`;
} }
@ -2494,6 +2513,7 @@ async function fetchFullConfig(configType = 'running') {
if (loadingMsg) { if (loadingMsg) {
loadingMsg.style.display = 'inline-block'; loadingMsg.style.display = 'inline-block';
loadingMsg.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色
loadingMsg.innerHTML = '⏳ 準備連線至設備...'; loadingMsg.innerHTML = '⏳ 準備連線至設備...';
} }
if (treeContainer) treeContainer.innerHTML = ''; if (treeContainer) treeContainer.innerHTML = '';
@ -2502,7 +2522,6 @@ async function fetchFullConfig(configType = 'running') {
let progressInterval = null; let progressInterval = null;
try { try {
// --- 1. 版本守門員 (維持原樣) ---
try { try {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass); const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') { if (versionRes.status === 'success') {
@ -2524,7 +2543,6 @@ async function fetchFullConfig(configType = 'running') {
console.warn("版本檢查失敗,略過", vErr); console.warn("版本檢查失敗,略過", vErr);
} }
// --- 2. 載入完整配置 (ReadableStream 串流接收) ---
const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`); const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
@ -2559,26 +2577,20 @@ async function fetchFullConfig(configType = 'running') {
} }
}, 500); }, 500);
} else { } else {
// 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
if (progressInterval) { if (progressInterval) {
clearInterval(progressInterval); clearInterval(progressInterval);
progressInterval = null; progressInterval = null;
// 1. 強制填滿 100% 並給予成功提示
if (loadingMsg) { if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`; loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感 loadingMsg.style.color = "#27ae60";
} }
// 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
await new Promise(resolve => setTimeout(resolve, 600)); await new Promise(resolve => setTimeout(resolve, 600));
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色
// 3. 恢復原本的顏色 }
if (loadingMsg) loadingMsg.style.color = ""; if (loadingMsg) {
loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色
loadingMsg.innerHTML = data.message;
} }
// 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
if (loadingMsg) loadingMsg.innerHTML = data.message;
} }
} }
else if (data.status === 'success') { else if (data.status === 'success') {
@ -2597,7 +2609,6 @@ async function fetchFullConfig(configType = 'running') {
window.currentTreeData = finalTreeData; window.currentTreeData = finalTreeData;
// --- 3. 防呆攔截與畫面渲染 ---
const currentSelectedTask = document.getElementById('configTask').value; const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config'; const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
@ -2625,7 +2636,7 @@ async function fetchFullConfig(configType = 'running') {
if (progressInterval) clearInterval(progressInterval); if (progressInterval) clearInterval(progressInterval);
if (loadingMsg) { if (loadingMsg) {
loadingMsg.style.display = 'none'; loadingMsg.style.display = 'none';
loadingMsg.style.color = ""; // 確保重置顏 loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘
} }
} }
} }
@ -2888,12 +2899,11 @@ async function openSystemSettings() {
} }
} }
// 載入真實設備配置以供過濾器勾選 // 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
async function loadRealConfigForFilters() { async function loadRealConfigForFilters() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!"); if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!");
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select'); const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running'; const mode = modeSelect ? modeSelect.value : 'running';
@ -2901,26 +2911,89 @@ async function loadRealConfigForFilters() {
const container = document.getElementById('filter-checkboxes'); const container = document.getElementById('filter-checkboxes');
loadingMsg.style.display = 'inline-block'; loadingMsg.style.display = 'inline-block';
loadingMsg.style.color = '#f39c12'; // 🌟 確保橘色
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
container.style.display = 'none'; container.style.display = 'none';
try { let progressInterval = null; // 🌟 新增:進度條計時器
// 🌟 根據模式載入對應的配置檔 (Running 或 Full)
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') { try {
container.style.display = 'block'; const url = `/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
// 🌟 傳遞 mode 給 buildRealFilterTree const response = await fetch(url);
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys, false, mode);
} else { if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`; const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finalTreeData = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (${Math.round(progress)}%)...`;
} }
}, 500);
} else {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色
}
await new Promise(resolve => setTimeout(resolve, 600));
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 恢復橘色
}
if (loadingMsg) {
loadingMsg.style.color = '#f39c12';
loadingMsg.innerHTML = data.message;
}
}
}
else if (data.status === 'success') {
finalTreeData = data.data;
}
else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (finalTreeData) {
container.style.display = 'block';
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
} else {
throw new Error("未收到完整的配置資料");
}
} catch (error) { } catch (error) {
container.style.display = 'block'; container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`; container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally { } finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
loadingMsg.style.display = 'none'; loadingMsg.style.display = 'none';
} }
} }
@ -2980,9 +3053,8 @@ async function saveSystemSettings() {
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾 // 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = []; let allBackupsData = [];
// 1. 建立新快照 // 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
async function createSnapshot() { async function createSnapshot() {
// 🛡️ 防線一:必須先連線才能備份
if (!isConnected) { if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)"); return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
} }
@ -2993,7 +3065,6 @@ async function createSnapshot() {
const snapshotName = document.getElementById('snapshotName').value.trim(); const snapshotName = document.getElementById('snapshotName').value.trim();
if (!snapshotName) return alert("請輸入快照名稱!"); if (!snapshotName) return alert("請輸入快照名稱!");
// 🟢 抓取描述輸入框的值
const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || ""; const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
const configType = document.getElementById('backupConfigType').value; const configType = document.getElementById('backupConfigType').value;
const btn = document.getElementById('btnCreateSnapshot'); const btn = document.getElementById('btnCreateSnapshot');
@ -3001,6 +3072,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.innerHTML = '⏳ 準備連線至設備...';
let progressInterval = null; // 🌟 新增:進度條計時器
try { try {
const response = await fetch('/api/v1/backups/snapshot', { const response = await fetch('/api/v1/backups/snapshot', {
@ -3011,25 +3086,84 @@ async function createSnapshot() {
username: connInfo.user, username: connInfo.user,
password: connInfo.pass, password: connInfo.pass,
snapshot_name: snapshotName, snapshot_name: snapshotName,
description: snapshotDescription, // 🟢 將描述傳給後端 description: snapshotDescription,
config_type: configType config_type: configType
}) })
}); });
const result = await response.json();
if (result.status === 'success') { if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
alert(`✅ ${result.message}`);
document.getElementById('snapshotName').value = ''; // 清空名稱輸入框 const reader = response.body.getReader();
if (document.getElementById('snapshotDescription')) { const decoder = new TextDecoder("utf-8");
document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框 let buffer = "";
let isSuccess = false;
let finalMessage = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (msg) {
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
} }
loadBackupHistory(); // 重新載入歷史紀錄 }, 500);
} else { } else {
alert(`❌ 備份失敗: ${result.message}`); if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (msg) {
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
msg.style.color = "#27ae60"; // 瞬間變綠色
}
await new Promise(resolve => setTimeout(resolve, 600));
if (msg) msg.style.color = "#f39c12"; // 恢復橘色
}
if (msg) {
msg.style.color = '#f39c12';
msg.innerHTML = data.message;
}
}
} else if (data.status === 'success') {
isSuccess = true;
finalMessage = data.message;
} else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
if (e.message) throw e;
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (isSuccess) {
alert(`✅ ${finalMessage}`);
document.getElementById('snapshotName').value = '';
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = '';
}
loadBackupHistory();
} }
} catch (error) { } catch (error) {
alert(`❌ 發生錯誤: ${error.message}`); alert(`❌ 發生錯誤: ${error.message}`);
} finally { } finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
btn.disabled = false; btn.disabled = false;
msg.style.display = 'none'; msg.style.display = 'none';
} }
@ -3044,7 +3178,8 @@ async function loadBackupHistory() {
const tbody = document.getElementById('backup-history-tbody'); const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return; if (!tbody) return;
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: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
try { try {
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來 // 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
@ -3203,8 +3338,9 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
// 1. 移除進度條,改為純文字百分比動態顯示 // 1. 移除進度條,改為純文字百分比動態顯示
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`; modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
// 🌟 修正:將原本的藍色改為橘色
modalOutput.innerHTML = ` modalOutput.innerHTML = `
<div id="diff-progress-text" style="color: #3498db; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置,並與快照進行深度比對... (0%)</div> <div id="diff-progress-text" style="color: #f39c12; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置,並與快照進行深度比對... (0%)</div>
`; `;
modal.classList.add('active'); modal.classList.add('active');
@ -4338,7 +4474,8 @@ export async function startEditLeaf(path, elementId) {
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`; // 🌟 修正:統一為標準橘色並加粗 (原為 #e67e22)
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
try { try {
let optData = await apiGetLeafOptions(host, currentMode); let optData = await apiGetLeafOptions(host, currentMode);
@ -4770,7 +4907,7 @@ async function applyEditLeaf(path, elementId) {
export function executeSideCLI() { export function executeSideCLI() {
const finalScript = document.getElementById('side-cli-textarea').value; const finalScript = document.getElementById('side-cli-textarea').value;
document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...'; document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
document.getElementById('side-pane-title').style.color = '#3498db'; document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db)
document.getElementById('btn-side-cancel').style.display = 'none'; document.getElementById('btn-side-cancel').style.display = 'none';
document.getElementById('btn-side-confirm').style.display = 'none'; document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-close').style.display = 'none'; document.getElementById('btn-side-close').style.display = 'none';
@ -6044,35 +6181,51 @@ 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("/snapshot", summary="手動建立設備快照") @router.post("/snapshot", summary="手動建立設備快照 (串流版)")
async def create_snapshot(req: SnapshotRequest): async def create_snapshot(req: SnapshotRequest):
async def backup_streamer():
try: try:
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) # 1. 廣播:正在連線
parsed_tree = parse_cli_to_tree(raw_cli) 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"
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( backup_id = await insert_config_backup(
host=req.host, host=req.host,
config_type=req.config_type, config_type=req.config_type,
raw_cli=raw_cli, raw_cli=raw_cli,
parsed_tree=parsed_tree, parsed_tree=parsed_tree,
snapshot_name=req.snapshot_name, snapshot_name=req.snapshot_name,
description=req.description, # 🟢 將描述傳遞給資料庫函數 description=req.description,
is_auto=False is_auto=False
) )
if not backup_id: if not backup_id:
return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"} yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
return
return { # 5. 廣播:成功
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
} }) + "\n"
except Exception as e: except Exception as e:
logger.error(f"❌ 建立快照失敗: {e}") logger.error(f"❌ 建立快照失敗: {e}")
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"} yield json.dumps({"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}) + "\n"
return StreamingResponse(backup_streamer(), media_type="application/x-ndjson")
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異") @router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
async def analyze_backup_diff(backup_id: str, req: RestoreRequest): async def analyze_backup_diff(backup_id: str, req: RestoreRequest):

View File

@ -149,35 +149,51 @@ 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("/snapshot", summary="手動建立設備快照") @router.post("/snapshot", summary="手動建立設備快照 (串流版)")
async def create_snapshot(req: SnapshotRequest): async def create_snapshot(req: SnapshotRequest):
async def backup_streamer():
try: try:
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) # 1. 廣播:正在連線
parsed_tree = parse_cli_to_tree(raw_cli) 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"
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( backup_id = await insert_config_backup(
host=req.host, host=req.host,
config_type=req.config_type, config_type=req.config_type,
raw_cli=raw_cli, raw_cli=raw_cli,
parsed_tree=parsed_tree, parsed_tree=parsed_tree,
snapshot_name=req.snapshot_name, snapshot_name=req.snapshot_name,
description=req.description, # 🟢 將描述傳遞給資料庫函數 description=req.description,
is_auto=False is_auto=False
) )
if not backup_id: if not backup_id:
return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"} yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
return
return { # 5. 廣播:成功
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
} }) + "\n"
except Exception as e: except Exception as e:
logger.error(f"❌ 建立快照失敗: {e}") logger.error(f"❌ 建立快照失敗: {e}")
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"} yield json.dumps({"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}) + "\n"
return StreamingResponse(backup_streamer(), media_type="application/x-ndjson")
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異") @router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
async def analyze_backup_diff(backup_id: str, req: RestoreRequest): async def analyze_backup_diff(backup_id: str, req: RestoreRequest):

View File

@ -425,7 +425,7 @@ function resetAllManagerData() {
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config) // 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
// ============================================================================ // ============================================================================
// 執行綜合查詢 (CM / RPD) // 執行綜合查詢 (CM / RPD) - 🌟 導入前端模擬動態訊息流
async function executeQuery(mode) { async function executeQuery(mode) {
if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕"); if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕");
@ -438,10 +438,28 @@ async function executeQuery(mode) {
openModal(connInfo.host); openModal(connInfo.host);
const outputEl = document.getElementById('modalOutput'); const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">正在向 ${connInfo.host} 查詢中,請稍候...</span>`;
// 🌟 模擬動態訊息流邏輯
const states = [
`🔌 正在建立 SSH 連線至 ${connInfo.host}...`,
`📥 正在向設備發送查詢指令...`,
`🧩 正在等待設備回傳資料...`
];
let progressIdx = 0;
outputEl.innerHTML = `<span id="query-progress-text" style="color: #f39c12; font-weight: bold;">${states[0]}</span>`;
const progressInterval = setInterval(() => {
progressIdx++;
if (progressIdx < states.length) {
const textEl = document.getElementById('query-progress-text');
if (textEl) textEl.innerText = states[progressIdx];
}
}, 1500); // 每 1.5 秒切換一次狀態
try { try {
const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass); const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
clearInterval(progressInterval); // 收到結果,停止輪播
if (result.status === 'success') { if (result.status === 'success') {
outputEl.style.color = "#e0e0e0"; outputEl.style.color = "#e0e0e0";
outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data; outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data;
@ -450,6 +468,7 @@ async function executeQuery(mode) {
outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`; outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
} }
} catch (error) { } catch (error) {
clearInterval(progressInterval); // 發生錯誤,停止輪播
outputEl.style.color = "#e74c3c"; outputEl.style.color = "#e74c3c";
outputEl.textContent = `連線失敗: ${error}`; outputEl.textContent = `連線失敗: ${error}`;
} }
@ -465,6 +484,7 @@ async function fetchFullConfig(configType = 'running') {
if (loadingMsg) { if (loadingMsg) {
loadingMsg.style.display = 'inline-block'; loadingMsg.style.display = 'inline-block';
loadingMsg.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色
loadingMsg.innerHTML = '⏳ 準備連線至設備...'; loadingMsg.innerHTML = '⏳ 準備連線至設備...';
} }
if (treeContainer) treeContainer.innerHTML = ''; if (treeContainer) treeContainer.innerHTML = '';
@ -473,7 +493,6 @@ async function fetchFullConfig(configType = 'running') {
let progressInterval = null; let progressInterval = null;
try { try {
// --- 1. 版本守門員 (維持原樣) ---
try { try {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass); const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') { if (versionRes.status === 'success') {
@ -495,7 +514,6 @@ async function fetchFullConfig(configType = 'running') {
console.warn("版本檢查失敗,略過", vErr); console.warn("版本檢查失敗,略過", vErr);
} }
// --- 2. 載入完整配置 (ReadableStream 串流接收) ---
const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`); const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
@ -530,26 +548,20 @@ async function fetchFullConfig(configType = 'running') {
} }
}, 500); }, 500);
} else { } else {
// 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
if (progressInterval) { if (progressInterval) {
clearInterval(progressInterval); clearInterval(progressInterval);
progressInterval = null; progressInterval = null;
// 1. 強制填滿 100% 並給予成功提示
if (loadingMsg) { if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`; loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感 loadingMsg.style.color = "#27ae60";
} }
// 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
await new Promise(resolve => setTimeout(resolve, 600)); await new Promise(resolve => setTimeout(resolve, 600));
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色
// 3. 恢復原本的顏色 }
if (loadingMsg) loadingMsg.style.color = ""; if (loadingMsg) {
loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色
loadingMsg.innerHTML = data.message;
} }
// 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
if (loadingMsg) loadingMsg.innerHTML = data.message;
} }
} }
else if (data.status === 'success') { else if (data.status === 'success') {
@ -568,7 +580,6 @@ async function fetchFullConfig(configType = 'running') {
window.currentTreeData = finalTreeData; window.currentTreeData = finalTreeData;
// --- 3. 防呆攔截與畫面渲染 ---
const currentSelectedTask = document.getElementById('configTask').value; const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config'; const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
@ -596,7 +607,7 @@ async function fetchFullConfig(configType = 'running') {
if (progressInterval) clearInterval(progressInterval); if (progressInterval) clearInterval(progressInterval);
if (loadingMsg) { if (loadingMsg) {
loadingMsg.style.display = 'none'; loadingMsg.style.display = 'none';
loadingMsg.style.color = ""; // 確保重置顏 loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘
} }
} }
} }
@ -859,12 +870,11 @@ async function openSystemSettings() {
} }
} }
// 載入真實設備配置以供過濾器勾選 // 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
async function loadRealConfigForFilters() { async function loadRealConfigForFilters() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!"); if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!");
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select'); const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running'; const mode = modeSelect ? modeSelect.value : 'running';
@ -872,26 +882,89 @@ async function loadRealConfigForFilters() {
const container = document.getElementById('filter-checkboxes'); const container = document.getElementById('filter-checkboxes');
loadingMsg.style.display = 'inline-block'; loadingMsg.style.display = 'inline-block';
loadingMsg.style.color = '#f39c12'; // 🌟 確保橘色
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
container.style.display = 'none'; container.style.display = 'none';
try { let progressInterval = null; // 🌟 新增:進度條計時器
// 🌟 根據模式載入對應的配置檔 (Running 或 Full)
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') { try {
container.style.display = 'block'; const url = `/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
// 🌟 傳遞 mode 給 buildRealFilterTree const response = await fetch(url);
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys, false, mode);
} else { if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`; const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finalTreeData = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (${Math.round(progress)}%)...`;
} }
}, 500);
} else {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色
}
await new Promise(resolve => setTimeout(resolve, 600));
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 恢復橘色
}
if (loadingMsg) {
loadingMsg.style.color = '#f39c12';
loadingMsg.innerHTML = data.message;
}
}
}
else if (data.status === 'success') {
finalTreeData = data.data;
}
else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (finalTreeData) {
container.style.display = 'block';
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
} else {
throw new Error("未收到完整的配置資料");
}
} catch (error) { } catch (error) {
container.style.display = 'block'; container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`; container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally { } finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
loadingMsg.style.display = 'none'; loadingMsg.style.display = 'none';
} }
} }
@ -951,9 +1024,8 @@ async function saveSystemSettings() {
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾 // 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = []; let allBackupsData = [];
// 1. 建立新快照 // 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
async function createSnapshot() { async function createSnapshot() {
// 🛡️ 防線一:必須先連線才能備份
if (!isConnected) { if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)"); return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
} }
@ -964,7 +1036,6 @@ async function createSnapshot() {
const snapshotName = document.getElementById('snapshotName').value.trim(); const snapshotName = document.getElementById('snapshotName').value.trim();
if (!snapshotName) return alert("請輸入快照名稱!"); if (!snapshotName) return alert("請輸入快照名稱!");
// 🟢 抓取描述輸入框的值
const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || ""; const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
const configType = document.getElementById('backupConfigType').value; const configType = document.getElementById('backupConfigType').value;
const btn = document.getElementById('btnCreateSnapshot'); const btn = document.getElementById('btnCreateSnapshot');
@ -972,6 +1043,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.innerHTML = '⏳ 準備連線至設備...';
let progressInterval = null; // 🌟 新增:進度條計時器
try { try {
const response = await fetch('/api/v1/backups/snapshot', { const response = await fetch('/api/v1/backups/snapshot', {
@ -982,25 +1057,84 @@ async function createSnapshot() {
username: connInfo.user, username: connInfo.user,
password: connInfo.pass, password: connInfo.pass,
snapshot_name: snapshotName, snapshot_name: snapshotName,
description: snapshotDescription, // 🟢 將描述傳給後端 description: snapshotDescription,
config_type: configType config_type: configType
}) })
}); });
const result = await response.json();
if (result.status === 'success') { if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
alert(`${result.message}`);
document.getElementById('snapshotName').value = ''; // 清空名稱輸入框 const reader = response.body.getReader();
if (document.getElementById('snapshotDescription')) { const decoder = new TextDecoder("utf-8");
document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框 let buffer = "";
let isSuccess = false;
let finalMessage = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (msg) {
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
} }
loadBackupHistory(); // 重新載入歷史紀錄 }, 500);
} else { } else {
alert(`❌ 備份失敗: ${result.message}`); if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (msg) {
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
msg.style.color = "#27ae60"; // 瞬間變綠色
}
await new Promise(resolve => setTimeout(resolve, 600));
if (msg) msg.style.color = "#f39c12"; // 恢復橘色
}
if (msg) {
msg.style.color = '#f39c12';
msg.innerHTML = data.message;
}
}
} else if (data.status === 'success') {
isSuccess = true;
finalMessage = data.message;
} else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
if (e.message) throw e;
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (isSuccess) {
alert(`${finalMessage}`);
document.getElementById('snapshotName').value = '';
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = '';
}
loadBackupHistory();
} }
} catch (error) { } catch (error) {
alert(`❌ 發生錯誤: ${error.message}`); alert(`❌ 發生錯誤: ${error.message}`);
} finally { } finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
btn.disabled = false; btn.disabled = false;
msg.style.display = 'none'; msg.style.display = 'none';
} }
@ -1015,7 +1149,8 @@ async function loadBackupHistory() {
const tbody = document.getElementById('backup-history-tbody'); const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return; if (!tbody) return;
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: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
try { try {
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來 // 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
@ -1174,8 +1309,9 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
// 1. 移除進度條,改為純文字百分比動態顯示 // 1. 移除進度條,改為純文字百分比動態顯示
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`; modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
// 🌟 修正:將原本的藍色改為橘色
modalOutput.innerHTML = ` modalOutput.innerHTML = `
<div id="diff-progress-text" style="color: #3498db; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置並與快照進行深度比對... (0%)</div> <div id="diff-progress-text" style="color: #f39c12; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置並與快照進行深度比對... (0%)</div>
`; `;
modal.classList.add('active'); modal.classList.add('active');

View File

@ -314,7 +314,8 @@ export async function startEditLeaf(path, elementId) {
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`; // 🌟 修正:統一為標準橘色並加粗 (原為 #e67e22)
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
try { try {
let optData = await apiGetLeafOptions(host, currentMode); let optData = await apiGetLeafOptions(host, currentMode);
@ -746,7 +747,7 @@ async function applyEditLeaf(path, elementId) {
export function executeSideCLI() { export function executeSideCLI() {
const finalScript = document.getElementById('side-cli-textarea').value; const finalScript = document.getElementById('side-cli-textarea').value;
document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...'; document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
document.getElementById('side-pane-title').style.color = '#3498db'; document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db)
document.getElementById('btn-side-cancel').style.display = 'none'; document.getElementById('btn-side-cancel').style.display = 'none';
document.getElementById('btn-side-confirm').style.display = 'none'; document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-close').style.display = 'none'; document.getElementById('btn-side-close').style.display = 'none';