feat: 導入 SSE 串流機制與 API 路由標準化

1. 實作 StreamingResponse 即時回傳爬蟲進度。2. 新增 IS_SCANNING 全域變數防止併發掃描。3. 統一 main.py 路由前綴,修復心跳機制 404 錯誤。
This commit is contained in:
swpa 2026-05-13 18:31:34 +08:00
parent 93eb15ac9c
commit 3fea4a972a
7 changed files with 9557 additions and 206 deletions

File diff suppressed because it is too large Load Diff

View File

@ -142,8 +142,10 @@ def parse_device_response(output: str) -> dict:
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()} return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list) -> dict: async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
try: try:
total_paths = len(leaf_paths)
processed_count = 0
result_data = {} result_data = {}
BATCH_SIZE = 30 BATCH_SIZE = 30
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)] batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
@ -229,12 +231,22 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list) ->
result_data[path] = parsed_data result_data[path] = parsed_data
# 🌟 核心修改:每處理完一個路徑,就推播一次進度!
processed_count += 1
event_data = {
"event": "progress",
"current": processed_count,
"total": total_paths,
"current_path": path
}
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.0) await read_until_quiet(timeout=1.0)
except Exception as e: except Exception as e:
print(f"❌ 第 {batch_idx + 1} 批次發生錯誤: {str(e)}") yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
continue continue
finally: finally:
if conn: conn.close() if conn: conn.close()
@ -261,7 +273,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list) ->
if batch_idx < len(batches) - 1: if batch_idx < len(batches) - 1:
await asyncio.sleep(2) await asyncio.sleep(2)
return {"status": "success", "data": result_data} yield json.dumps({"event": "done", "message": "快取更新完成"}) + "\n"
except Exception as e: except Exception as e:
return {"status": "error", "message": f"AsyncSSH 爬蟲錯誤: {str(e)}"} yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"

14
main.py
View File

@ -12,12 +12,14 @@ app = FastAPI(title="Harmonic CMTS Manager", version="2.0")
# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js) # 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
# 註冊 API 路由 # 🌟 統一標準:所有 REST API 都在這裡掛載 /api/v1 前綴
app.include_router(query.router, prefix="/api/v1", tags=["Query"]) app.include_router(query.router, prefix="/api/v1")
app.include_router(config.router, prefix="/api/v1", tags=["Config"]) app.include_router(config.router, prefix="/api/v1")
app.include_router(terminal.router, tags=["Terminal"]) app.include_router(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1/locks") app.include_router(lock.router, prefix="/api/v1")
app.include_router(leaf_options.router)
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router)
# 根目錄路由:回傳前端 UI # 根目錄路由:回傳前端 UI
@app.get("/", response_class=HTMLResponse, tags=["UI"]) @app.get("/", response_class=HTMLResponse, tags=["UI"])

View File

@ -1,5 +1,6 @@
# --- routers/leaf_options.py --- # --- routers/leaf_options.py ---
from fastapi import APIRouter, BackgroundTasks, HTTPException, Body from fastapi import APIRouter, BackgroundTasks, HTTPException, Body
from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
import json, os, asyncio, time, re import json, os, asyncio, time, re
@ -8,69 +9,16 @@ import json, os, asyncio, time, re
from cmts_scraper import sync_cmts_leaves_async from cmts_scraper import sync_cmts_leaves_async
from shared import CMTS_DEVICE from shared import CMTS_DEVICE
router = APIRouter(prefix="/api/v1", tags=["Options"]) # 🌟 移除原本硬寫的 prefix="/api/v1",只留下 tags
router = APIRouter(tags=["Options"])
CACHE_FILE = "cmts_leaf_options_cache.json" CACHE_FILE = "cmts_leaf_options_cache.json"
scraper_semaphore = asyncio.Semaphore(1)
# 🌟 1. 改用全域布林變數,狀態切換更即時,消滅非同步時間差
IS_SCANNING = False
class SyncOptionsRequest(BaseModel): class SyncOptionsRequest(BaseModel):
leaf_paths: List[str] leaf_paths: List[str]
async def run_scraper_task_async(leaf_paths: List[str]):
if scraper_semaphore.locked():
print("⚠️ [Debug] 爬蟲正在執行中,本次請求被忽略")
return
async with scraper_semaphore:
try:
print(f"🚀 [Debug] 背景爬蟲開始啟動,準備抓取: {leaf_paths}")
# 🌟 雙重防護:確保傳給 CMTS 的是空白分隔,並在後端也徹底拔除 [0], [1] 等虛擬索引
cmts_query_paths = []
for p in leaf_paths:
clean_p = p.replace("::", " ")
clean_p = re.sub(r"\s*\[\d+\]", "", clean_p) # 拔除虛擬索引
cmts_query_paths.append(clean_p)
# 去除重複的路徑 (例如 alias [0] 和 alias [1] 淨化後都會變成 alias)
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
print(f"⏳ [Debug] 正在連線 CMTS 執行 sync_cmts_leaves_async...")
# 🌟 核心修改:直接 await 非同步爬蟲,不再需要 Netmiko 與 to_thread
# 從共用的 CMTS_DEVICE 字典中提取連線資訊
result = await sync_cmts_leaves_async(
host=CMTS_DEVICE.get("host"),
username=CMTS_DEVICE.get("username"),
password=CMTS_DEVICE.get("password"),
leaf_paths=cmts_query_paths
)
print(f"✅ [Debug] CMTS 抓取完成,結果: {result['status']}")
if result["status"] == "success":
cache_data = {}
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "r", encoding="utf-8") as f:
cache_data = json.load(f)
current_timestamp = int(time.time())
# 存入快取
for cmts_path, data in result["data"].items():
data["updated_at"] = current_timestamp
cache_data[cmts_path] = data
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache_data, f, indent=4, ensure_ascii=False)
print("💾 [Debug] 快取檔案寫入成功!")
else:
error_msg = result.get('message', '沒有提供錯誤訊息')
print(f"❌ [Debug] 抓取失敗CMTS 拒絕了我們!原因: {error_msg}")
except Exception as e:
print(f"💥 [Debug] 背景爬蟲發生嚴重錯誤: {e}")
@router.get("/cmts-leaf-options") @router.get("/cmts-leaf-options")
async def get_leaf_options(): async def get_leaf_options():
if not os.path.exists(CACHE_FILE): return {} if not os.path.exists(CACHE_FILE): return {}
@ -78,10 +26,49 @@ async def get_leaf_options():
return json.load(f) return json.load(f)
@router.post("/cmts-leaf-options/sync") @router.post("/cmts-leaf-options/sync")
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks): async def sync_leaf_options(request: SyncOptionsRequest):
if not request.leaf_paths: raise HTTPException(status_code=400) global IS_SCANNING # 宣告我們要修改全域變數
background_tasks.add_task(run_scraper_task_async, request.leaf_paths)
return {"status": "processing"} if not request.leaf_paths:
raise HTTPException(status_code=400)
# 🌟 2. 第一時間檢查並「立即」阻擋
if IS_SCANNING:
async def busy_stream():
yield json.dumps({"event": "error", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}) + "\n"
return StreamingResponse(busy_stream(), media_type="text/event-stream")
# 如果沒人掃描,立刻把狀態改為 True (在建立串流之前就上鎖)
IS_SCANNING = True
async def event_generator():
global IS_SCANNING
try:
cmts_query_paths = []
for p in request.leaf_paths:
clean_p = p.replace("::", " ")
clean_p = re.sub(r"\s*\[\d+\]", "", clean_p)
cmts_query_paths.append(clean_p)
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
# 🌟 因為爬蟲自己會存檔,我們只要專心把進度轉發給前端就好!
async for chunk in sync_cmts_leaves_async(
host=CMTS_DEVICE.get("host"),
username=CMTS_DEVICE.get("username"),
password=CMTS_DEVICE.get("password"),
leaf_paths=cmts_query_paths
):
yield chunk
except Exception as e:
yield json.dumps({"event": "error", "message": str(e)}) + "\n"
finally:
# 🌟 3. 無論成功、失敗、或使用者提早關閉網頁,都確保會解鎖
IS_SCANNING = False
return StreamingResponse(event_generator(), media_type="text/event-stream")
# 🌟 新增:清除特定選項快取的 API (使用您原本定義的 CACHE_FILE) # 🌟 新增:清除特定選項快取的 API (使用您原本定義的 CACHE_FILE)
@router.post("/clear_cache") @router.post("/clear_cache")

View File

@ -4,7 +4,8 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, Optional from typing import Dict, Optional
router = APIRouter() # 🌟 只定義自己的子路徑
router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ========================================== # ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table) # 💡 全域鎖定表 (In-Memory Lock Table)

View File

@ -119,3 +119,40 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
const response = await fetch(url); const response = await fetch(url);
return response.json(); return response.json();
} }
// ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ==========================================
export async function apiSyncLeafOptionsStream(paths, onProgress) {
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: paths })
});
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
// 🌟 核心修改:讀取串流資料
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
while (true) {
const { done, value } = await reader.read();
if (done) break; // 伺服器斷開連線 (任務完成)
// 解碼二進位資料為文字
const chunk = decoder.decode(value, { stream: true });
// 因為一次可能收到多行 JSON我們用換行符號切開
const lines = chunk.split("\n").filter(line => line.trim() !== "");
for (const line of lines) {
try {
const data = JSON.parse(line);
onProgress(data); // 將解析後的資料傳給 UI 介面
} catch (e) {
console.error("JSON 解析錯誤:", e, line);
}
}
}
}

View File

@ -10,7 +10,8 @@ import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, dow
// 2. API 網路請求模組 (API) // 2. API 網路請求模組 (API)
import { import {
apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -334,49 +335,20 @@ async function scanAllMissingOptions() {
return; return;
} }
statusEl.innerHTML = `⏳ 正在背景掃描 <b>${pathsToSync.length}</b> 個欄位的選項...`; statusEl.innerHTML = `⏳ 正在建立即時連線...`;
statusEl.style.color = "#e67e22"; statusEl.style.color = "#e67e22";
const response = await fetch('/api/v1/cmts-leaf-options/sync', { // 🌟 核心修改:改用串流 API並傳入進度更新的回呼函數
method: 'POST', await apiSyncLeafOptionsStream(pathsToSync, (data) => {
headers: { 'Content-Type': 'application/json' }, if (data.event === 'progress') {
body: JSON.stringify({ leaf_paths: pathsToSync }) // 收到進度:即時更新畫面數字與當前路徑
}); statusEl.innerHTML = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${data.current_path})`;
statusEl.style.color = "#f39c12";
if (response.ok) { }
statusEl.innerHTML = `✅ 任務已送出!正在監控背景執行進度...`; else if (data.event === 'done') {
statusEl.style.color = "#3498db"; // 掃描完成:恢復按鈕狀態並顯示成功
checkScanProgress(pathsToSync); statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
} else { statusEl.style.color = "#27ae60";
throw new Error("API 回應錯誤");
}
} catch (error) {
console.error("掃描請求失敗:", error);
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
statusEl.style.color = "#e74c3c";
}
}
// 智慧監控背景掃描進度
async function checkScanProgress(pendingPaths) {
const btn = document.getElementById('global-scan-btn');
const statusEl = document.getElementById('scan-status');
let attempts = 0;
const maxAttempts = 60; // 最多監控 5 分鐘 (每 5 秒查一次)
const interval = setInterval(async () => {
attempts++;
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json();
const remainingPaths = pendingPaths.filter(path => !optData[path]);
const doneCount = pendingPaths.length - remainingPaths.length;
if (remainingPaths.length === 0) {
clearInterval(interval);
localStorage.removeItem('scanLockUntil');
if (btn) { if (btn) {
btn.disabled = false; btn.disabled = false;
@ -384,24 +356,29 @@ async function checkScanProgress(pendingPaths) {
btn.style.cursor = "pointer"; btn.style.cursor = "pointer";
btn.innerText = "🔍 一鍵掃描缺失選項"; btn.innerText = "🔍 一鍵掃描缺失選項";
} }
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000);
} else if (attempts >= maxAttempts) {
clearInterval(interval);
localStorage.removeItem('scanLockUntil'); localStorage.removeItem('scanLockUntil');
if (typeof enableGlobalScanButton === 'function') enableGlobalScanButton(); setTimeout(() => statusEl.textContent = "", 5000);
statusEl.innerHTML = `⚠️ 掃描耗時較長 (${doneCount}/${pendingPaths.length}),已轉為純背景執行。`;
statusEl.style.color = "#f39c12";
} else {
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${doneCount} / ${pendingPaths.length}</b> 完成...`;
} }
} catch (e) { else if (data.event === 'error') {
console.warn("監控進度時發生網路錯誤,稍後重試...", e); // 發生錯誤:顯示紅字並恢復按鈕
} statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
}, 5000); statusEl.style.color = "#e74c3c";
if (btn) {
btn.disabled = false;
btn.style.backgroundColor = "#8e44ad";
btn.style.cursor = "pointer";
btn.innerText = "🔍 一鍵掃描缺失選項";
}
localStorage.removeItem('scanLockUntil');
}
});
} catch (error) {
console.error("掃描請求失敗:", error);
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
statusEl.style.color = "#e74c3c";
}
} }
// 清除畫面選項快取功能 (精準可見度判定版) // 清除畫面選項快取功能 (精準可見度判定版)