feat(c4): ADR-073-C C4 — 飛輪人工介入路徑視覺化
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 14m5s
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:
138
apps/web/src/components/dashboard/flywheel-diagram.tsx
Normal file
138
apps/web/src/components/dashboard/flywheel-diagram.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user