Files
awoooi/apps/web/src/components/aiops/timeline/EvidenceViewer.tsx
Your Name 1096da12ae feat(p2.5): aiops 時序前端面板 — Incident 6 階段視覺化
Wave 6 P2.5 frontend-designer 工業級視覺化(拒絕 AI slop):

新增(1824 行):
- apps/web/src/app/[locale]/aiops/timeline/page.tsx
- apps/web/src/components/aiops/timeline/
  · AiopsTimelinePanel.tsx (413) — 主面板組件
  · TimelineStage.tsx (279) — 6 階段時序卡片
  · TimelineStageDetails.tsx (359) — 階段細節展開
  · EvidenceViewer.tsx (144) — Evidence Snapshot 檢視
  · TimelineFilter.tsx (109) — incident_id / severity / 時段 過濾器
  · types.ts (118) — TS 型別定義
  · mock-data.ts (357) — 開發 mock fallback
  · index.ts (7) — barrel export
- i18n: messages/en.json + messages/zh-TW.json — Timeline 翻譯

設計原則:
- 拒絕 AI slop(無泛用 emoji/漸層,採工業 dashboard 風格)
- 後端 endpoint 接通 /api/v1/aiops/timeline(critic B4 修復)
- mock 模式 fallback 防 endpoint 暫時不可達

對應後端: a3b4595e(aiops_timeline.py + aiops_timeline_service.py)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: frontend-designer agent (Wave 6) <noreply@anthropic.com>
2026-04-27 08:11:40 +08:00

145 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
// 2026-04-26 P2.5 by Claude — AIOps Timeline
// ============================================================
// EvidenceViewer — 8D evidence 視覺化
// Tab 切換不同 dimension每個 dimension 顯示狀態 + 詳情
// ============================================================
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { CheckCircle2, AlertTriangle, HelpCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { EvidenceDimension } from './types'
interface EvidenceViewerProps {
dimensions: EvidenceDimension[]
}
const DIMENSION_STATUS_CONFIG = {
ok: { icon: CheckCircle2, color: 'text-status-healthy', bg: 'bg-status-healthy/10', label: 'OK' },
anomaly: { icon: AlertTriangle, color: 'text-status-critical', bg: 'bg-status-critical/10', label: 'ANOMALY' },
unknown: { icon: HelpCircle, color: 'text-nothing-gray-400', bg: 'bg-nothing-gray-100', label: 'UNKNOWN' },
}
export function EvidenceViewer({ dimensions }: EvidenceViewerProps) {
const t = useTranslations('aiopsTimeline')
const [activeTab, setActiveTab] = useState(0)
const active = dimensions[activeTab]
return (
<div className="mt-3">
{/* Tab nav — 8 個 dimension 可滾動 */}
<div
className="flex gap-1 overflow-x-auto pb-1 scrollbar-none"
role="tablist"
aria-label={t('evidence.dimensions')}
>
{dimensions.map((dim, i) => {
const cfg = DIMENSION_STATUS_CONFIG[dim.status]
return (
<button
key={dim.key}
role="tab"
aria-selected={i === activeTab}
aria-controls={`evidence-panel-${dim.key}`}
id={`evidence-tab-${dim.key}`}
onClick={() => setActiveTab(i)}
className={cn(
'flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-body transition-all',
i === activeTab
? cn('border font-medium', cfg.bg,
dim.status === 'anomaly' ? 'border-status-critical/30 text-status-critical'
: dim.status === 'ok' ? 'border-status-healthy/30 text-status-healthy'
: 'border-nothing-gray-300 text-nothing-gray-600')
: 'text-nothing-gray-500 hover:text-nothing-gray-700 hover:bg-nothing-gray-100'
)}
>
<cfg.icon className={cn('w-3 h-3', i === activeTab ? cfg.color : 'text-nothing-gray-400')} />
<span className="truncate max-w-[80px]">{dim.name}</span>
</button>
)
})}
</div>
{/* Active dimension detail */}
{active && (
<div
id={`evidence-panel-${active.key}`}
role="tabpanel"
aria-labelledby={`evidence-tab-${active.key}`}
className={cn(
'mt-2 p-3 rounded-lg border',
active.status === 'anomaly'
? 'bg-status-critical/5 border-status-critical/20'
: active.status === 'ok'
? 'bg-status-healthy/5 border-status-healthy/20'
: 'bg-nothing-gray-50 border-nothing-gray-200'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
{(() => {
const cfg = DIMENSION_STATUS_CONFIG[active.status]
return (
<>
<cfg.icon className={cn('w-3.5 h-3.5 flex-shrink-0', cfg.color)} />
<span className={cn('text-xs font-body font-bold uppercase tracking-wider', cfg.color)}>
{cfg.label}
</span>
</>
)
})()}
</div>
<p className="font-body text-sm font-medium text-nothing-black">
{active.name}
</p>
{active.detail && (
<p className="font-body text-xs text-nothing-gray-500 mt-0.5">
{active.detail}
</p>
)}
</div>
<div className="flex-shrink-0 text-right">
<span className="font-body text-sm font-bold text-nothing-black tabular-nums">
{active.value !== null && active.value !== undefined ? String(active.value) : t('evidence.noData')}
</span>
</div>
</div>
</div>
)}
{/* Summary row: anomaly count */}
<div className="flex items-center gap-3 mt-2">
<span className="text-[11px] font-body text-nothing-gray-400">
{t('evidence.anomalyCount', {
count: dimensions.filter(d => d.status === 'anomaly').length,
total: dimensions.length,
})}
</span>
{/* Mini status dots */}
<div className="flex gap-1 items-center">
{dimensions.map((d, i) => (
<div
key={d.key}
title={d.name}
className={cn(
'w-2 h-2 rounded-full transition-all',
i === activeTab && 'ring-2 ring-offset-1',
d.status === 'anomaly' ? 'bg-status-critical'
: d.status === 'ok' ? 'bg-status-healthy'
: 'bg-nothing-gray-300',
i === activeTab && d.status === 'anomaly' ? 'ring-status-critical/40'
: i === activeTab && d.status === 'ok' ? 'ring-status-healthy/40'
: i === activeTab ? 'ring-nothing-gray-400/40'
: ''
)}
/>
))}
</div>
</div>
</div>
)
}