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:
@@ -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
|
||||
|
||||
@@ -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/summary(HTTP 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) =>
|
||||
|
||||
Reference in New Issue
Block a user