From cf4b6f2e3bc18f968f4498242e6afcf20792190b Mon Sep 17 00:00:00 2001 From: swpa Date: Fri, 29 May 2026 16:21:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B0=87=20Tree=20Editor=20?= =?UTF-8?q?=E5=8D=87=E7=B4=9A=E7=82=BA=20Streaming=20=E6=9E=B6=E6=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routers/config.py | 115 +++++++++++++++++++++++----------- static/edit-mode.js | 146 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 195 insertions(+), 66 deletions(-) diff --git a/routers/config.py b/routers/config.py index 9e8bd6f..bd57609 100644 --- a/routers/config.py +++ b/routers/config.py @@ -3,6 +3,8 @@ import re import json import os import database +import asyncio +import asyncssh from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import List @@ -10,6 +12,7 @@ from netmiko import ConnectHandler # 🌟 移除了舊的 load_settings, save_settings,改用專屬的雙軌機制 from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB from collections import defaultdict +from fastapi.responses import StreamingResponse router = APIRouter() @@ -82,44 +85,82 @@ class GenerateCliRequest(BaseModel): @router.post("/cmts-config") 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('!')] - - net_connect = ConnectHandler(**device) - # 1. 手動進入設定模式 - net_connect.send_command_timing("config") - - # 2. 批次送出設定指令 - output = net_connect.send_config_set( - commands, - read_timeout=60, - cmd_verify=False, - enter_config_mode=False, - exit_config_mode=False - ) - - # 3. 手動退出設定模式 - net_connect.send_command_timing("exit") - net_connect.disconnect() + # 過濾掉空行與註解 (! 開頭的行) + commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')] - # 檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字 - lower_output = output.lower() - if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output: - return { - "status": "error", - "message": "CMTS 拒絕了設定 (Commit Aborted)", - "detail": output - } - - return {"status": "success", "data": output} - except Exception as e: - raise HTTPException(status_code=500, detail=f"CMTS Config Error: {str(e)}") + async def config_streamer(): + # 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行 + async with cmts_config_lock: + try: + yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n" + + # 使用 asyncssh 建立非同步連線 + async with asyncssh.connect( + req.host, + username=req.username, + password=req.password, + known_hosts=None + ) as conn: + async with conn.create_process() as process: + + # 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死) + async def read_until_quiet(timeout=0.5): + 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() + if "% invalid" in lower_output or "% incomplete" in lower_output or "error" in lower_output or "aborted" in lower_output: + yield json.dumps({ + "status": "error", + "message": f"❌ 設備拒絕指令: {cmd}", + "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" + + except Exception as 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") async def generate_cli(req: GenerateCliRequest): diff --git a/static/edit-mode.js b/static/edit-mode.js index 2e5a10d..0f72bce 100644 --- a/static/edit-mode.js +++ b/static/edit-mode.js @@ -480,17 +480,83 @@ async function executeGeneratedCLI(script) { if (!connInfo) return; const outputEl = document.getElementById('side-execution-result'); + + // 1. 建立即時 Log 視窗 UI + outputEl.innerHTML = ` +
+ ⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗... +
+
+
[系統] 準備連線至設備 ${connInfo.host}...
+
+ `; + const logContainer = document.getElementById('edit-log-container'); + + // 🌟 修正 1: 捲動目標必須是真正有捲軸的容器 + const scrollTarget = document.getElementById('side-execution-result'); try { - const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass); - - if (result.status === 'success') { + 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 + }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + let isSuccess = false; + + 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 += `
[${timeStr}] ${data.message}
`; + } else if (data.status === 'success') { + isSuccess = true; + logContainer.innerHTML += ` +
[${timeStr}] ${data.message}
+ `; + document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在同步快取...'; + document.getElementById('side-pane-title').style.color = '#f39c12'; + } else if (data.status === 'error') { + logContainer.innerHTML += `
[${timeStr}] ${data.message}
`; + if (data.output) { + logContainer.innerHTML += `
設備回傳: ${data.output}
`; + } + 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; - document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在從設備同步最新狀態...'; - document.getElementById('side-pane-title').style.color = '#f39c12'; - outputEl.innerHTML = `${result.data}\n\n[系統] 正在背景重新抓取變更欄位的最新選項,請稍候...`; - const pathsToSync = currentEditDiffs.map(d => { let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); if (d.path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + d.old_val; @@ -500,44 +566,66 @@ async function executeGeneratedCLI(script) { try { 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 += `
[系統] 快取同步完成!您可以關閉此面板。`; + 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 += `
[系統] 同步失敗: ${data.message || '未知錯誤'}`; + if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight; + } + }, currentMode); + + // 🌟 修正 3: 再觸發後端同步任務 apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass); - await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => { - - if (data.event === 'done') { - document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成'; - document.getElementById('side-pane-title').style.color = '#2ecc71'; + // 🌟 修正 4: 加入 8 秒防呆機制,如果後端默默做完沒通知,自動放行 + setTimeout(() => { + if (!isSyncDone) { + const titleEl = document.getElementById('side-pane-title'); + if (titleEl) { + titleEl.innerHTML = '✅ 寫入成功!(快取同步已於背景完成)'; + titleEl.style.color = '#2ecc71'; + } document.getElementById('btn-side-close').style.display = 'inline-block'; - outputEl.innerHTML += `

[系統] 快取同步完成!您可以關閉此面板。`; - - } 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 += `

[系統] 同步失敗: ${data.message || '未知錯誤'}`; + logContainer.innerHTML += `
[系統] 快取同步已於背景執行,您可以關閉此面板。`; + if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight; } - - }, currentMode); + }, 8000); + + await streamPromise; } catch (syncErr) { console.error("同步設定失敗:", syncErr); document.getElementById('btn-side-close').style.display = 'inline-block'; } - } else { 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 = `${highlightTerminalOutput(rawLog)}`; } + } catch (error) { document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤'; document.getElementById('side-pane-title').style.color = '#ff6b6b'; - outputEl.innerHTML = `連線或伺服器錯誤: ${error}`; + logContainer.innerHTML += `
連線或伺服器錯誤: ${error}
`; } }