feat(c4): ADR-073-C C4 — 飛輪人工介入路徑視覺化
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 14m5s

新增 FlywheelDiagram SVG 元件:
- 六節點流程圖(監控→去重→診斷→推理→執行→學習)
- TYPE-3 觸發時:紅色虛線 推理→人工處理中心
- TYPE-4 觸發時:橙色虛線 推理→根因確認
- 活躍節點高亮 + incident 計數徽章
- 整合進 FlywheelKPICard(消費 /api/v1/stats/flywheel)

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-12 15:41:33 +08:00
parent 0c2892ac19
commit 9b1812cdef
2 changed files with 154 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
'use client'
/**
* FlywheelDiagram — ADR-073-C C4
*
* 飛輪六節點圖(監控→去重→診斷→推理→執行→學習)
* 人工介入路徑視覺化:
* TYPE-3人工審核紅色虛線 推理→人工處理中心
* TYPE-4根因確認橙色虛線 推理→TYPE-4 根因確認
*
* Props: currentFlow — 來自 GET /api/v1/stats/flywheel 的 current_flow 陣列
*
* 2026-04-12 ogt (ADR-073-C C4)
*/
export interface FlowItem {
incident_id: string
alertname: string
current_node: string
ts: string | null
}
interface FlywheelDiagramProps {
currentFlow?: FlowItem[]
/** active node names from node_stats */
activeNodes?: Record<string, { status: string }>
}
const NODES = [
{ id: 'monitoring', label: '監控', x: 10, y: 50 },
{ id: 'deduplication', label: '去重', x: 26, y: 25 },
{ id: 'diagnosis', label: '診斷', x: 50, y: 12 },
{ id: 'reasoning', label: '推理匹配', x: 74, y: 25 },
{ id: 'execution', label: '執行', x: 90, y: 50 },
{ id: 'learning', label: '學習', x: 74, y: 75 },
]
// SVG viewport: 100 × 100 (percentage-based)
const R = 5 // node circle radius
const W = 400
const H = 160
function toSVG(pct: { x: number; y: number }) {
return { cx: (pct.x / 100) * W, cy: (pct.y / 100) * H }
}
export function FlywheelDiagram({ currentFlow = [], activeNodes = {} }: FlywheelDiagramProps) {
// Count active incidents per node
const nodeCounts: Record<string, number> = {}
for (const f of currentFlow) {
nodeCounts[f.current_node] = (nodeCounts[f.current_node] ?? 0) + 1
}
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
// Build arc path through nodes in order
const pts = NODES.map(n => toSVG(n))
const arcD = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.cx} ${p.cy}`).join(' ')
// Reasoning node coords (index 3)
const reasoningPt = toSVG(NODES[3])
// Manual intervention point (right of SVG)
const manualPt = { cx: W * 0.98, cy: H * 0.50 }
// TYPE-4 point (below reasoning)
const type4Pt = { cx: W * 0.74, cy: H * 0.92 }
return (
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
style={{ display: 'block', overflow: 'visible' }}
aria-label="飛輪六節點流程圖"
>
{/* Main flywheel arc */}
<path d={arcD} fill="none" stroke="#e0ddd4" strokeWidth={1.5} />
{/* Return arc (learning → monitoring) */}
<path
d={`M ${toSVG(NODES[5]).cx} ${toSVG(NODES[5]).cy} Q ${W * 0.3} ${H * 0.95} ${toSVG(NODES[0]).cx} ${toSVG(NODES[0]).cy}`}
fill="none" stroke="#e0ddd4" strokeWidth={1.5} strokeDasharray="4 3"
/>
{/* TYPE-3 intervention path */}
{hasType3 && (
<>
<line
x1={reasoningPt.cx} y1={reasoningPt.cy}
x2={manualPt.cx} y2={manualPt.cy}
stroke="#d97757" strokeWidth={1.5} strokeDasharray="5 3"
opacity={0.85}
/>
<circle cx={manualPt.cx} cy={manualPt.cy} r={R} fill="#fff" stroke="#d97757" strokeWidth={1.5} />
<text x={manualPt.cx} y={manualPt.cy - R - 3} textAnchor="middle"
fontSize={8} fill="#d97757" fontWeight={600}></text>
</>
)}
{/* TYPE-4 intervention path */}
{hasType4 && (
<>
<line
x1={reasoningPt.cx} y1={reasoningPt.cy}
x2={type4Pt.cx} y2={type4Pt.cy}
stroke="#F59E0B" strokeWidth={1.5} strokeDasharray="5 3"
opacity={0.85}
/>
<circle cx={type4Pt.cx} cy={type4Pt.cy} r={R} fill="#fff" stroke="#F59E0B" strokeWidth={1.5} />
<text x={type4Pt.cx} y={type4Pt.cy + R + 9} textAnchor="middle"
fontSize={8} fill="#F59E0B" fontWeight={600}></text>
</>
)}
{/* Nodes */}
{NODES.map(node => {
const { cx, cy } = toSVG(node)
const count = nodeCounts[node.id] ?? 0
const isActive = activeNodes[node.id]?.status === 'active' || count > 0
const fill = isActive ? '#d97757' : '#fff'
const stroke = isActive ? '#d97757' : '#c5c2b8'
return (
<g key={node.id}>
<circle cx={cx} cy={cy} r={R + 1} fill={fill} stroke={stroke} strokeWidth={1.5} />
{count > 0 && (
<text x={cx} y={cy + 1.5} textAnchor="middle" dominantBaseline="middle"
fontSize={6} fill="#fff" fontWeight={700}>{count}</text>
)}
<text x={cx} y={cy + R + 9} textAnchor="middle" fontSize={8}
fill={isActive ? '#141413' : '#87867f'} fontWeight={isActive ? 600 : 400}>
{node.label}
</text>
</g>
)
})}
</svg>
)
}

View File

@@ -11,6 +11,7 @@
*/
import { useEffect, useRef, useState } from 'react'
import { FlywheelDiagram, type FlowItem } from './flywheel-diagram'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
// ws(s):// mirror of NEXT_PUBLIC_API_URL
@@ -30,6 +31,7 @@ interface FlywheelSummary {
export function FlywheelKPICard() {
const [data, setData] = useState<FlywheelSummary | null>(null)
const [flowData, setFlowData] = useState<{ current_flow: FlowItem[]; nodes: Record<string, { status: string }> } | null>(null)
const [error, setError] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
@@ -45,6 +47,12 @@ export function FlywheelKPICard() {
.catch(() => { if (!cancelled) setError(true) })
}
// 載入飛輪節點狀態C4 用)
fetch(`${API_BASE}/api/v1/stats/flywheel`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (!cancelled && d) setFlowData(d) })
.catch(() => {})
load()
// C3: WebSocket — upgrades from polling when available
@@ -162,6 +170,14 @@ export function FlywheelKPICard() {
))}
</div>
{/* C4: 飛輪節點圖 + 人工介入路徑 */}
<div style={{ padding: '10px 14px', borderTop: '0.5px solid #e0ddd4' }}>
<FlywheelDiagram
currentFlow={flowData?.current_flow ?? []}
activeNodes={flowData?.nodes ?? {}}
/>
</div>
{/* Stuck incidents warning */}
{data?.incidents_stuck != null && data.incidents_stuck > 0 && (
<div style={{