From 65a5220e16322f2d6090e69e01534f3a2b6c8c8d Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 12 Apr 2026 18:45:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(flywheel-c2-c3):=20C2=20hasType4=E6=8E=A5?= =?UTF-8?q?=E7=9C=9F=E5=AF=A6API=20+=20C3=20WebSocket=E6=8C=87=E6=95=B8?= =?UTF-8?q?=E9=80=80=E9=81=BF=E9=87=8D=E9=80=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2: flywheel_stats_service 加 type4_count query → API 回傳 flywheel-diagram.tsx hasType4 改由 type4Count prop 驅動(非 false) flywheel-kpi-card.tsx 傳入 type4Count={flowData?.type4_count} C3: WebSocket onclose 加指數退避重連 (1s→2s→4s→最大30s) cancelled 旗標確保 unmount 後不重連 wsRetryTimer 加入 cleanup Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/flywheel_stats_service.py | 21 ++++++++++++--- .../components/dashboard/flywheel-diagram.tsx | 6 +++-- .../dashboard/flywheel-kpi-card.tsx | 26 +++++++++++++------ 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/api/src/services/flywheel_stats_service.py b/apps/api/src/services/flywheel_stats_service.py index 915c6c53..14353223 100644 --- a/apps/api/src/services/flywheel_stats_service.py +++ b/apps/api/src/services/flywheel_stats_service.py @@ -79,6 +79,7 @@ class FlywheelMetrics: self.node_stats = node_stats self.current_flow = current_flow self.computed_at = computed_at + self.type4_count: int = 0 # TYPE-4 incidents 數(ADR-073-C C2) def to_prometheus_lines(self) -> str: """輸出 Prometheus text format""" @@ -115,6 +116,7 @@ class FlywheelMetrics: return { "nodes": self.node_stats, "current_flow": self.current_flow, + "type4_count": self.type4_count, "computed_at": self.computed_at.isoformat(), } @@ -162,9 +164,10 @@ class FlywheelStatsService: today_processed, node_stats, current_flow, + type4_count, ) = await self._incident_stats(now) - return FlywheelMetrics( + metrics = FlywheelMetrics( playbook_count=playbook_count, execution_success_rate=execution_success_rate, km_unvectorized_count=km_unvectorized_count, @@ -177,6 +180,8 @@ class FlywheelStatsService: current_flow=current_flow, computed_at=now, ) + metrics.type4_count = type4_count + return metrics # ------------------------------------------------------------------ # Internal helpers @@ -274,6 +279,16 @@ class FlywheelStatsService: ) incidents_stuck = stuck_q.scalar_one_or_none() or 0 + # TYPE-4 Incident 數(ADR-073-C C2 — 供前端 hasType4 判斷) + # 2026-04-12 ogt + type4_q = await db.execute( + select(func.count()).where( + IncidentRecord.notification_type == "TYPE-4", + IncidentRecord.status == IncidentStatus.INVESTIGATING.value, + ) + ) + type4_count = type4_q.scalar_one_or_none() or 0 + # 今日處理數 today_q = await db.execute( select(func.count()).where( @@ -355,11 +370,11 @@ class FlywheelStatsService: }, } - return alertname_null_rate, incidents_stuck, today_processed, node_stats, current_flow + return alertname_null_rate, incidents_stuck, today_processed, node_stats, current_flow, type4_count except Exception: logger.exception("flywheel_stats_incident_error") - return 0.0, 0, 0, {n: {"status": "unknown"} for n in FLYWHEEL_NODES}, [] + return 0.0, 0, 0, {n: {"status": "unknown"} for n in FLYWHEEL_NODES}, [], 0 def _status_to_node(status: str) -> str: diff --git a/apps/web/src/components/dashboard/flywheel-diagram.tsx b/apps/web/src/components/dashboard/flywheel-diagram.tsx index c062a46f..e0fa3fea 100644 --- a/apps/web/src/components/dashboard/flywheel-diagram.tsx +++ b/apps/web/src/components/dashboard/flywheel-diagram.tsx @@ -24,6 +24,8 @@ interface FlywheelDiagramProps { currentFlow?: FlowItem[] /** active node names from node_stats */ activeNodes?: Record + /** TYPE-4 active incident count from API (ADR-073-C C2) */ + type4Count?: number } const NODES = [ @@ -44,7 +46,7 @@ function toSVG(pct: { x: number; y: number }) { return { cx: (pct.x / 100) * W, cy: (pct.y / 100) * H } } -export function FlywheelDiagram({ currentFlow = [], activeNodes = {} }: FlywheelDiagramProps) { +export function FlywheelDiagram({ currentFlow = [], activeNodes = {}, type4Count = 0 }: FlywheelDiagramProps) { // Count active incidents per node const nodeCounts: Record = {} for (const f of currentFlow) { @@ -52,7 +54,7 @@ export function FlywheelDiagram({ currentFlow = [], activeNodes = {} }: Flywheel } const hasType3 = currentFlow.some(f => f.current_node === 'reasoning' || f.current_node === 'execution') - const hasType4 = false // TYPE-4 shown when diagnosis node has stuck items + const hasType4 = type4Count > 0 // Build arc path through nodes in order const pts = NODES.map(n => toSVG(n)) diff --git a/apps/web/src/components/dashboard/flywheel-kpi-card.tsx b/apps/web/src/components/dashboard/flywheel-kpi-card.tsx index 086ee812..1a164f96 100644 --- a/apps/web/src/components/dashboard/flywheel-kpi-card.tsx +++ b/apps/web/src/components/dashboard/flywheel-kpi-card.tsx @@ -31,7 +31,7 @@ interface FlywheelSummary { export function FlywheelKPICard() { const [data, setData] = useState(null) - const [flowData, setFlowData] = useState<{ current_flow: FlowItem[]; nodes: Record } | null>(null) + const [flowData, setFlowData] = useState<{ current_flow: FlowItem[]; nodes: Record; type4_count?: number } | null>(null) const [error, setError] = useState(false) const wsRef = useRef(null) @@ -55,14 +55,18 @@ export function FlywheelKPICard() { load() - // C3: WebSocket — upgrades from polling when available + // C3: WebSocket — upgrades from polling when available, with reconnect (ADR-073-C C3) + // 指數退避重連:1s → 2s → 4s → 8s → 最大 30s + let wsRetryDelay = 1000 + let wsRetryTimer: ReturnType | null = null + const connectWS = () => { - if (!WS_BASE) return + if (!WS_BASE || cancelled) return const ws = new WebSocket(`${WS_BASE}/api/v1/stats/flywheel/ws`) wsRef.current = ws ws.onopen = () => { - // WS connected — stop HTTP polling + wsRetryDelay = 1000 // 連線成功 — 重置退避 if (pollId) { clearInterval(pollId); pollId = null } } ws.onmessage = (e) => { @@ -75,10 +79,14 @@ export function FlywheelKPICard() { } catch { /* ignore malformed */ } } ws.onclose = () => { - // WS closed — fall back to polling - if (!cancelled && !pollId) { - pollId = setInterval(load, 30_000) - } + if (cancelled) return + // 退回 HTTP polling + if (!pollId) pollId = setInterval(load, 30_000) + // 指數退避重連 + wsRetryTimer = setTimeout(() => { + wsRetryDelay = Math.min(wsRetryDelay * 2, 30_000) + connectWS() + }, wsRetryDelay) } ws.onerror = () => ws.close() } @@ -90,6 +98,7 @@ export function FlywheelKPICard() { return () => { cancelled = true if (pollId) clearInterval(pollId) + if (wsRetryTimer) clearTimeout(wsRetryTimer) wsRef.current?.close() } }, []) @@ -175,6 +184,7 @@ export function FlywheelKPICard() {