refactor: 將 Tree Editor 升級為 Streaming 架構
This commit is contained in:
parent
61241c796e
commit
cf4b6f2e3b
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue