diff --git a/apps/api/src/api/v1/stats.py b/apps/api/src/api/v1/stats.py index cec093fc..fe7c227a 100644 --- a/apps/api/src/api/v1/stats.py +++ b/apps/api/src/api/v1/stats.py @@ -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):///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 diff --git a/apps/web/src/components/dashboard/flywheel-kpi-card.tsx b/apps/web/src/components/dashboard/flywheel-kpi-card.tsx index df4a6639..8796305f 100644 --- a/apps/web/src/components/dashboard/flywheel-kpi-card.tsx +++ b/apps/web/src/components/dashboard/flywheel-kpi-card.tsx @@ -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(null) const [error, setError] = useState(false) + const wsRef = useRef(null) + // C2: HTTP fallback (initial load + 30s poll when WS unavailable) useEffect(() => { let cancelled = false + let pollId: ReturnType | 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) =>