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}
`;
}
}