feat(c3): ADR-073-C C3 — WebSocket 飛輪即時推送

後端:
- stats.py 新增 @router.websocket('/flywheel/ws')
- 每 10 秒推送 flywheel_summary JSON

前端 FlywheelKPICard:
- WebSocket 優先,WS 斷線自動降級到 30s HTTP 輪詢
- onopen 時停止 HTTP polling,onclose 時恢復

2026-04-12 ogt (ADR-073-C C3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-12 15:40:20 +08:00
parent 4b51f9b60d
commit 0c2892ac19
2 changed files with 84 additions and 8 deletions

View File

@@ -19,9 +19,11 @@
# @see feedback_lewooogo_modular_enforcement.md
# =============================================================================
import asyncio
import json
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field
@@ -552,3 +554,35 @@ async def get_flywheel_prometheus_metrics(svc: FlywheelStatsDep) -> PlainTextRes
content=metrics.to_prometheus_lines(),
media_type="text/plain; version=0.0.4; charset=utf-8",
)
# =============================================================================
# ADR-073-C C3: WebSocket 即時飛輪推送
# =============================================================================
@router.websocket("/flywheel/ws")
async def flywheel_websocket(websocket: WebSocket) -> None:
"""
WebSocket 即時飛輪健康度推送 — ADR-073-C C3
每 10 秒推送一次 FlywheelSummary JSON。
前端連線路徑ws(s)://<host>/api/v1/stats/flywheel/ws
Protocol:
Server → Client: {"type": "flywheel_summary", "data": {...}, "ts": "ISO8601"}
Client → Server: (ignored)
"""
svc = get_flywheel_stats_service()
await websocket.accept()
try:
while True:
metrics = await svc.compute()
payload = json.dumps({
"type": "flywheel_summary",
"data": metrics.to_summary_api_dict(),
"ts": metrics.computed_at.isoformat(),
})
await websocket.send_text(payload)
await asyncio.sleep(10)
except WebSocketDisconnect:
pass

View File

@@ -1,17 +1,20 @@
'use client'
/**
* FlywheelKPICard — ADR-073-C C2
* FlywheelKPICard — ADR-073-C C2 + C3
*
* 飛輪健康度 KPI 面板,消費 GET /api/v1/stats/summary
* 30 秒輪詢,無快取假數據。
* 飛輪健康度 KPI 面板。
* C2: 初始載入 GET /api/v1/stats/summaryHTTP fallback
* C3: WebSocket /api/v1/stats/flywheel/ws 即時推送10s 更新)
*
* 2026-04-12 ogt (ADR-073-C C2)
* 2026-04-12 ogt (ADR-073-C C2 + C3)
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
// ws(s):// mirror of NEXT_PUBLIC_API_URL
const WS_BASE = API_BASE.replace(/^https/, 'wss').replace(/^http/, 'ws')
interface FlywheelSummary {
playbook_count: number
@@ -28,9 +31,12 @@ interface FlywheelSummary {
export function FlywheelKPICard() {
const [data, setData] = useState<FlywheelSummary | null>(null)
const [error, setError] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
// C2: HTTP fallback (initial load + 30s poll when WS unavailable)
useEffect(() => {
let cancelled = false
let pollId: ReturnType<typeof setInterval> | null = null
const load = () => {
fetch(`${API_BASE}/api/v1/stats/summary`)
@@ -40,8 +46,44 @@ export function FlywheelKPICard() {
}
load()
const id = setInterval(load, 30_000)
return () => { cancelled = true; clearInterval(id) }
// C3: WebSocket — upgrades from polling when available
const connectWS = () => {
if (!WS_BASE) return
const ws = new WebSocket(`${WS_BASE}/api/v1/stats/flywheel/ws`)
wsRef.current = ws
ws.onopen = () => {
// WS connected — stop HTTP polling
if (pollId) { clearInterval(pollId); pollId = null }
}
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data)
if (msg.type === 'flywheel_summary' && !cancelled) {
setData(msg.data)
setError(false)
}
} catch { /* ignore malformed */ }
}
ws.onclose = () => {
// WS closed — fall back to polling
if (!cancelled && !pollId) {
pollId = setInterval(load, 30_000)
}
}
ws.onerror = () => ws.close()
}
connectWS()
// Also start polling as backup until WS opens
pollId = setInterval(load, 30_000)
return () => {
cancelled = true
if (pollId) clearInterval(pollId)
wsRef.current?.close()
}
}, [])
const fmt = (n: number | undefined, digits = 0) =>