feat(web): Tab3 Chain-of-Thought 面板 + Tab4 by_anomaly Top5 + MTTR
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 13m1s

Tab 3 ActivityStreamTab:
- 點擊 SSE 事件展開 COT 側面板(含 provider/confidence/latency/tools/reasoning)
- 有 proposal_data 的事件顯示 COT badge
- 點擊同一事件收合面板

Tab 4 DispositionTab:
- by_anomaly Top5 水平進度條(按 auto-repair 率著色:≥80% 綠/≥50% 橙/其他紅)
- MTTR 大字顯示(分鐘)+ 無資料時 fallback

i18n: cotTitle/cotReasoning/cotConfidence/cotProvider/cotLatency/cotTools/
      cotClickHint/byAnomalyTitle/byAnomalyAutoRate/mttrTitle/mttrUnit/mttrNoData

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 20:42:02 +08:00
parent 3bdac2e68e
commit ab5ba7062c
3 changed files with 158 additions and 13 deletions

View File

@@ -204,7 +204,20 @@
"viewAllReport": "View Full Report",
"aiModelStatus": "AI Model Status",
"loading": "Loading...",
"trendUp": "↑{pct}%"
"trendUp": "↑{pct}%",
"cotTitle": "Reasoning Timeline",
"cotNoEvents": "Waiting for reasoning data...",
"cotReasoning": "Reasoning",
"cotConfidence": "Confidence",
"cotProvider": "Model",
"cotLatency": "Latency",
"cotTools": "Tool Calls",
"cotClickHint": "Click an event to view reasoning details",
"byAnomalyTitle": "Anomaly Type Distribution Top 5",
"byAnomalyAutoRate": "Auto-Repair Rate",
"mttrTitle": "MTTR Overview",
"mttrUnit": "s",
"mttrNoData": "No MTTR data yet"
},
"openclaw": {
"name": "OpenClaw",

View File

@@ -205,7 +205,20 @@
"viewAllReport": "查看完整報表",
"aiModelStatus": "AI 模型狀態",
"loading": "載入中...",
"trendUp": "↑{pct}%"
"trendUp": "↑{pct}%",
"cotTitle": "推理時間軸",
"cotNoEvents": "等待事件推理資料...",
"cotReasoning": "推理",
"cotConfidence": "信心",
"cotProvider": "模型",
"cotLatency": "耗時",
"cotTools": "工具呼叫",
"cotClickHint": "點擊事件查看推理細節",
"byAnomalyTitle": "異常類型分佈 Top 5",
"byAnomalyAutoRate": "自動修復率",
"mttrTitle": "MTTR 概覽",
"mttrUnit": "秒",
"mttrNoData": "尚無 MTTR 資料"
},
"openclaw": {
"name": "OpenClaw",

View File

@@ -117,6 +117,7 @@ function ActivityStreamTab() {
const t = useTranslations('dashboard')
const [events, setEvents] = useState<any[]>([])
const [connected, setConnected] = useState(false)
const [selectedIdx, setSelectedIdx] = useState<number | null>(null)
useEffect(() => {
const es = new EventSource(`${API_BASE}/api/v1/dashboard/stream`)
@@ -131,6 +132,9 @@ function ActivityStreamTab() {
return () => es.close()
}, [])
const selected = selectedIdx !== null ? events[selectedIdx] : null
const cot = selected?.data?.incident?.decision?.proposal_data ?? selected?.data?.proposal_data ?? null
return (
<div style={{ padding: '16px 20px', flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
@@ -140,17 +144,83 @@ function ActivityStreamTab() {
</div>
{events.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>{t('waitingEvents')}</div>
) : events.map((ev, i) => (
<div key={i} style={{ display: 'flex', gap: 8, padding: '6px 0', borderBottom: '0.5px solid #f0ede5', fontSize: 12 }}>
<span style={{ fontSize: 10, color: '#87867f', fontFamily: "'JetBrains Mono', monospace", width: 60, flexShrink: 0 }}>{ev._time}</span>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: ev.type === 'HOST_UPDATE' ? '#22C55E' : '#4A90D9', marginTop: 5, flexShrink: 0 }} />
<span style={{ flex: 1, lineHeight: 1.4 }}>
<b>{ev.type || 'EVENT'}</b>
{ev.data?.overall_status && ` · ${t('statusLabel')}: ${ev.data.overall_status}`}
{ev.data?.hosts && ` · ${ev.data.hosts.length} ${t('hostsLabel')}`}
</span>
) : (
<div style={{ display: 'grid', gridTemplateColumns: cot ? '1fr 1fr' : '1fr', gap: 12 }}>
{/* Event list */}
<div>
{events.map((ev, i) => (
<div
key={i}
onClick={() => setSelectedIdx(i === selectedIdx ? null : i)}
style={{
display: 'flex', gap: 8, padding: '7px 8px', borderBottom: '0.5px solid #f0ede5',
fontSize: 12, cursor: 'pointer', borderRadius: 5,
background: i === selectedIdx ? 'rgba(74,144,217,0.06)' : 'transparent',
}}
>
<span style={{ fontSize: 10, color: '#87867f', fontFamily: "'JetBrains Mono', monospace", width: 60, flexShrink: 0 }}>{ev._time}</span>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: ev.type === 'HOST_UPDATE' ? '#22C55E' : '#4A90D9', marginTop: 5, flexShrink: 0 }} />
<span style={{ flex: 1, lineHeight: 1.4 }}>
<b>{ev.type || 'EVENT'}</b>
{ev.data?.overall_status && ` · ${t('statusLabel')}: ${ev.data.overall_status}`}
{ev.data?.hosts && ` · ${ev.data.hosts.length} ${t('hostsLabel')}`}
{(ev.data?.incident?.decision?.proposal_data || ev.data?.proposal_data) && (
<span style={{ marginLeft: 4, fontSize: 9, background: 'rgba(74,144,217,0.12)', color: '#4A90D9', borderRadius: 3, padding: '1px 5px', fontWeight: 600 }}>COT</span>
)}
</span>
</div>
))}
{selectedIdx === null && events.length > 0 && (
<div style={{ marginTop: 8, fontSize: 10, color: '#87867f', textAlign: 'center' }}>{t('cotClickHint')}</div>
)}
</div>
{/* Chain of Thought panel */}
{cot && (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px', fontSize: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#141413', marginBottom: 10 }}>{t('cotTitle')}</div>
{/* Provider + confidence row */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{cot.provider && (
<span style={{ fontSize: 10, background: 'rgba(74,144,217,0.1)', color: '#4A90D9', borderRadius: 4, padding: '2px 8px', fontWeight: 600 }}>
{t('cotProvider')}: {cot.provider}
</span>
)}
{cot.confidence !== undefined && (
<span style={{ fontSize: 10, background: cot.confidence >= 0.8 ? 'rgba(34,197,94,0.1)' : 'rgba(245,158,11,0.1)', color: cot.confidence >= 0.8 ? '#22C55E' : '#F59E0B', borderRadius: 4, padding: '2px 8px', fontWeight: 600 }}>
{t('cotConfidence')}: {Math.round(cot.confidence * 100)}%
</span>
)}
{cot.nemotron_latency_ms !== undefined && (
<span style={{ fontSize: 10, background: 'rgba(168,85,247,0.08)', color: '#A855F7', borderRadius: 4, padding: '2px 8px', fontWeight: 600 }}>
{t('cotLatency')}: {cot.nemotron_latency_ms}ms
</span>
)}
</div>
{/* Tools used */}
{Array.isArray(cot.nemotron_tools) && cot.nemotron_tools.length > 0 && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#87867f', marginBottom: 4 }}>{t('cotTools')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{cot.nemotron_tools.map((tool: string, i: number) => (
<span key={i} style={{ fontSize: 10, background: '#f5f4ed', border: '0.5px solid #e0ddd4', borderRadius: 3, padding: '2px 6px', fontFamily: "'JetBrains Mono', monospace" }}>{tool}</span>
))}
</div>
</div>
)}
{/* Reasoning */}
{cot.reasoning && (
<div>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#87867f', marginBottom: 4 }}>{t('cotReasoning')}</div>
<div style={{ fontSize: 11, color: '#555550', lineHeight: 1.5, background: '#faf9f3', border: '0.5px solid #e0ddd4', borderRadius: 6, padding: '8px 10px', maxHeight: 180, overflowY: 'auto', fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'pre-wrap' }}>
{cot.reasoning}
</div>
</div>
)}
</div>
)}
</div>
))}
)}
</div>
)
}
@@ -216,7 +286,7 @@ function DispositionTab() {
))}
</div>
{/* 堆疊分佈條 */}
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px', marginBottom: 16 }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', marginBottom: 8 }}>{t('dispositionBreakdown')}</div>
<div style={{ display: 'flex', height: 12, borderRadius: 6, overflow: 'hidden' }}>
{s.total > 0 && <>
@@ -227,6 +297,55 @@ function DispositionTab() {
</>}
</div>
</div>
{/* By Anomaly Top 5 */}
{Array.isArray(data?.by_anomaly) && data.by_anomaly.length > 0 && (() => {
const top5 = [...data.by_anomaly].sort((a: any, b: any) => b.count - a.count).slice(0, 5)
const maxCount = top5[0]?.count || 1
return (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px', marginBottom: 16 }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', marginBottom: 10 }}>{t('byAnomalyTitle')}</div>
{top5.map((item: any, i: number) => {
const autoRate = item.count > 0 ? Math.round((item.auto_repair ?? 0) / item.count * 100) : 0
return (
<div key={i} style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, marginBottom: 3 }}>
<span style={{ color: '#141413', fontWeight: 500, maxWidth: '70%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.anomaly_type || item.type || '--'}</span>
<span style={{ color: '#87867f', fontFamily: "'JetBrains Mono', monospace", fontSize: 10 }}>
{item.count} · {t('byAnomalyAutoRate', { pct: autoRate })}
</span>
</div>
<div style={{ height: 6, background: '#f0ede5', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${(item.count / maxCount) * 100}%`, background: autoRate >= 80 ? '#22C55E' : autoRate >= 50 ? '#F59E0B' : '#cc2200', borderRadius: 3, transition: 'width 0.4s ease' }} />
</div>
</div>
)
})}
</div>
)
})()}
{/* MTTR */}
{(() => {
const mttr = data?.mttr
if (!mttr) return (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', marginBottom: 6 }}>{t('mttrTitle')}</div>
<div style={{ fontSize: 11, color: '#87867f' }}>{t('mttrNoData')}</div>
</div>
)
const mttrVal = typeof mttr === 'number' ? mttr : mttr.avg_seconds ?? mttr.value ?? null
const mttrMin = mttrVal !== null ? (mttrVal / 60).toFixed(1) : '--'
return (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', marginBottom: 6 }}>{t('mttrTitle')}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 32, fontWeight: 700, color: '#4A90D9' }}>{mttrMin}</span>
<span style={{ fontSize: 11, color: '#87867f' }}>{t('mttrUnit')}</span>
</div>
</div>
)
})()}
</div>
)
}