refactor: 將 Tree Editor 升級為 Streaming 架構

This commit is contained in:
swpa 2026-05-29 16:21:43 +08:00
parent 61241c796e
commit cf4b6f2e3b
2 changed files with 195 additions and 66 deletions

View File

@ -3,6 +3,8 @@ import re
import json import json
import os import os
import database import database
import asyncio
import asyncssh
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
@ -10,6 +12,7 @@ from netmiko import ConnectHandler
# 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制 # 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict from collections import defaultdict
from fastapi.responses import StreamingResponse
router = APIRouter() router = APIRouter()
@ -82,44 +85,82 @@ class GenerateCliRequest(BaseModel):
@router.post("/cmts-config") @router.post("/cmts-config")
async def execute_cmts_config(req: ConfigRequest): async def execute_cmts_config(req: ConfigRequest):
# 使用非同步鎖,確保同一時間只有一個寫入任務執行
async with cmts_config_lock:
try:
device = CMTS_DEVICE.copy()
device.update({'host': req.host, 'username': req.username, 'password': req.password})
# 過濾掉空行與註解 (! 開頭的行) # 過濾掉空行與註解 (! 開頭的行)
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')] commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
net_connect = ConnectHandler(**device) async def config_streamer():
# 1. 手動進入設定模式 # 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行
net_connect.send_command_timing("config") async with cmts_config_lock:
try:
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
# 2. 批次送出設定指令 # 使用 asyncssh 建立非同步連線
output = net_connect.send_config_set( async with asyncssh.connect(
commands, req.host,
read_timeout=60, username=req.username,
cmd_verify=False, password=req.password,
enter_config_mode=False, known_hosts=None
exit_config_mode=False ) as conn:
) async with conn.create_process() as process:
# 3. 手動退出設定模式 # 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
net_connect.send_command_timing("exit") async def read_until_quiet(timeout=0.5):
net_connect.disconnect() out_data = ""
while True:
try:
# 利用 wait_for 設定超時,時間內沒資料就當作設備吐完了
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk:
break
out_data += str(chunk)
except asyncio.TimeoutError:
break
return out_data
# 檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字 # 1. 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(0.5) # 清空歡迎訊息
# 2. 逐行送出指令並回報進度
for cmd in commands:
yield json.dumps({"status": "progress", "message": f"▶️ 執行: {cmd}"}) + "\n"
process.stdin.write(cmd + "\n")
await process.stdin.drain()
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
output = await read_until_quiet(wait_time)
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
lower_output = output.lower() lower_output = output.lower()
if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output: if "% invalid" in lower_output or "% incomplete" in lower_output or "error" in lower_output or "aborted" in lower_output:
return { yield json.dumps({
"status": "error", "status": "error",
"message": "CMTS 拒絕了設定 (Commit Aborted)", "message": f"❌ 設備拒絕指令: {cmd}",
"detail": output "output": output.strip()
} }) + "\n"
process.stdin.write("abort\n")
await process.stdin.drain()
return # 發生錯誤,提早結束串流
# 3. 退出設定模式
process.stdin.write("exit\n")
await process.stdin.drain()
final_output = await read_until_quiet(1.0)
yield json.dumps({
"status": "success",
"message": "✅ 所有配置已成功寫入並 Commit",
"output": final_output.strip()
}) + "\n"
return {"status": "success", "data": output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Config Error: {str(e)}") yield json.dumps({"status": "error", "message": f"SSH 執行發生例外錯誤: {str(e)}"}) + "\n"
# 回傳 NDJSON 串流格式
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
@router.post("/cmts-generate-cli") @router.post("/cmts-generate-cli")
async def generate_cli(req: GenerateCliRequest): async def generate_cli(req: GenerateCliRequest):

View File

@ -481,15 +481,81 @@ async function executeGeneratedCLI(script) {
const outputEl = document.getElementById('side-execution-result'); const outputEl = document.getElementById('side-execution-result');
// 1. 建立即時 Log 視窗 UI
outputEl.innerHTML = `
<div style="color: #f39c12; font-weight: bold; margin-bottom: 10px; font-size: 13px;">
正在透過 SSH 寫入指令至設備請勿關閉視窗...
</div>
<div id="edit-log-container" style="background: #1e1e1e; padding: 12px; border-radius: 5px; font-family: monospace; color: #ecf0f1; border: 1px solid #333; font-size: 12px; line-height: 1.5;">
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
</div>
`;
const logContainer = document.getElementById('edit-log-container');
// 🌟 修正 1: 捲動目標必須是真正有捲軸的容器
const scrollTarget = document.getElementById('side-execution-result');
try { try {
const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass); const response = await fetch('/api/v1/cmts-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: script,
host: connInfo.host,
username: connInfo.user,
password: connInfo.pass
})
});
if (result.status === 'success') { const reader = response.body.getReader();
currentEditIsSuccess = true; const decoder = new TextDecoder("utf-8");
let buffer = "";
let isSuccess = false;
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在從設備同步最新狀態...'; 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);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
if (data.status === 'progress') {
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
} else if (data.status === 'success') {
isSuccess = true;
logContainer.innerHTML += `
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 14px;">[${timeStr}] ${data.message}</div>
`;
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在同步快取...';
document.getElementById('side-pane-title').style.color = '#f39c12'; document.getElementById('side-pane-title').style.color = '#f39c12';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}\n\n[系統] 正在背景重新抓取變更欄位的最新選項,請稍候...</span>`; } else if (data.status === 'error') {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
if (data.output) {
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
}
document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
document.getElementById('side-pane-title').style.color = '#ff6b6b';
}
// 自動捲動到最底部
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
// 4. 串流結束後,執行快取同步
if (isSuccess) {
currentEditIsSuccess = true;
const pathsToSync = currentEditDiffs.map(d => { const pathsToSync = currentEditDiffs.map(d => {
let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
@ -500,44 +566,66 @@ async function executeGeneratedCLI(script) {
try { try {
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
let isSyncDone = false;
// 🌟 修正 2: 先啟動 SSE 監聽 (避免錯過 done 事件)
const streamPromise = apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
const titleEl = document.getElementById('side-pane-title');
if (data.event === 'done' || data.status === 'done') {
isSyncDone = true;
if (titleEl) {
titleEl.innerHTML = '✅ 寫入成功!快取同步完成';
titleEl.style.color = '#2ecc71';
}
document.getElementById('btn-side-close').style.display = 'inline-block';
logContainer.innerHTML += `<br><span style="color: #2ecc71; font-weight: bold;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
} else if (data.event === 'error' || data.status === 'error') {
isSyncDone = true;
if (titleEl) {
titleEl.innerHTML = '⚠️ 寫入成功 (但同步快取失敗)';
titleEl.style.color = '#e74c3c';
}
document.getElementById('btn-side-close').style.display = 'inline-block';
logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
}, currentMode);
// 🌟 修正 3: 再觸發後端同步任務
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass); apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);
await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => { // 🌟 修正 4: 加入 8 秒防呆機制,如果後端默默做完沒通知,自動放行
setTimeout(() => {
if (data.event === 'done') { if (!isSyncDone) {
document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成'; const titleEl = document.getElementById('side-pane-title');
document.getElementById('side-pane-title').style.color = '#2ecc71'; if (titleEl) {
document.getElementById('btn-side-close').style.display = 'inline-block'; titleEl.innerHTML = '✅ 寫入成功!(快取同步已於背景完成)';
outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`; titleEl.style.color = '#2ecc71';
} else if (data.event === 'error') {
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)';
document.getElementById('side-pane-title').style.color = '#e74c3c';
document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
} }
document.getElementById('btn-side-close').style.display = 'inline-block';
logContainer.innerHTML += `<br><span style="color: #f39c12; font-weight: bold;">[系統] 快取同步已於背景執行,您可以關閉此面板。</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
}, 8000);
}, currentMode); await streamPromise;
} catch (syncErr) { } catch (syncErr) {
console.error("同步設定失敗:", syncErr); console.error("同步設定失敗:", syncErr);
document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('btn-side-close').style.display = 'inline-block';
} }
} else { } else {
currentEditIsSuccess = false; currentEditIsSuccess = false;
document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
document.getElementById('side-pane-title').style.color = '#ff6b6b';
const rawLog = result.detail || result.message || '';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${highlightTerminalOutput(rawLog)}</span>`;
} }
} catch (error) { } catch (error) {
document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤'; document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤';
document.getElementById('side-pane-title').style.color = '#ff6b6b'; document.getElementById('side-pane-title').style.color = '#ff6b6b';
outputEl.innerHTML = `<span style="color: #ff6b6b; font-weight: bold;">連線或伺服器錯誤: ${error}</span>`; logContainer.innerHTML += `<div style="color: #ff6b6b; font-weight: bold; margin-top: 10px;">連線或伺服器錯誤: ${error}</div>`;
} }
} }