diff --git a/apps/web/src/app/[locale]/aiops/timeline/page.tsx b/apps/web/src/app/[locale]/aiops/timeline/page.tsx new file mode 100644 index 00000000..27c68d4e --- /dev/null +++ b/apps/web/src/app/[locale]/aiops/timeline/page.tsx @@ -0,0 +1,38 @@ +'use client' + +// 2026-04-26 P2.5 by Claude — AIOps Timeline +// ============================================================ +// AIOps 全景時序頁面 +// 告警→感官調查→AI決策→自動執行→驗證→學習 完整鏈路視覺化 +// +// Mock 模式:NEXT_PUBLIC_AIOPS_TIMELINE_MOCK=true +// 真實 API: GET /api/v1/aiops/timeline?incident_id=&limit=20 +// ============================================================ + +import dynamic from 'next/dynamic' +import { AppLayout } from '@/components/layout' + +// SSR 跳過 — Timeline 面板含動畫與 client-only state +const AiopsTimelinePanel = dynamic( + () => import('@/components/aiops/timeline/AiopsTimelinePanel'), + { + ssr: false, + loading: () => ( +
+
+
+ ), + } +) + +interface AiopsTimelinePageProps { + params: { locale: string } +} + +export default function AiopsTimelinePage({ params }: AiopsTimelinePageProps) { + return ( + + + + ) +} diff --git a/apps/web/src/components/aiops/timeline/AiopsTimelinePanel.tsx b/apps/web/src/components/aiops/timeline/AiopsTimelinePanel.tsx new file mode 100644 index 00000000..1573887f --- /dev/null +++ b/apps/web/src/components/aiops/timeline/AiopsTimelinePanel.tsx @@ -0,0 +1,413 @@ +'use client' + +// 2026-04-26 P2.5 by Claude — AIOps Timeline +// ============================================================ +// AiopsTimelinePanel — 全景時序面板主體 +// 包含 header / filter / 事件列表 / per-incident timeline +// ============================================================ + +import { useState, useMemo, useCallback } from 'react' +import { useTranslations } from 'next-intl' +import { useQuery } from '@tanstack/react-query' +import { + GitBranch, + RefreshCw, + AlertCircle, + InboxIcon, + ChevronDown, + ChevronUp, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { TimelineFilter } from './TimelineFilter' +import { TimelineStage } from './TimelineStage' +import { MOCK_INCIDENTS } from './mock-data' +import type { + TimelineIncident, + TimelineFilterState, + StageType, +} from './types' + +// ============================================================ +// Constants +// ============================================================ + +const IS_MOCK = process.env.NEXT_PUBLIC_AIOPS_TIMELINE_MOCK === 'true' +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' + +const STAGE_ORDER: StageType[] = ['alert', 'diagnose', 'decide', 'execute', 'verify', 'learn'] + +const SEVERITY_CONFIG = { + P0: { bg: 'bg-status-critical/10', text: 'text-status-critical', border: 'border-status-critical/20' }, + P1: { bg: 'bg-status-warning/10', text: 'text-status-warning', border: 'border-status-warning/20' }, + P2: { bg: 'bg-claw-blue/10', text: 'text-claw-blue', border: 'border-claw-blue/20' }, + P3: { bg: 'bg-nothing-gray-100', text: 'text-nothing-gray-500', border: 'border-nothing-gray-200' }, +} + +// ============================================================ +// API fetch +// ============================================================ + +async function fetchTimeline(incidentId: string): Promise { + const params = new URLSearchParams() + if (incidentId) params.set('incident_id', incidentId) + params.set('limit', '20') + + const res = await fetch(`${API_BASE}/api/v1/aiops/timeline?${params.toString()}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = await res.json() + return json.data ?? json +} + +// ============================================================ +// IncidentCard — 單筆事件展開區塊 +// ============================================================ + +interface IncidentCardProps { + incident: TimelineIncident + index: number +} + +function IncidentCard({ incident, index }: IncidentCardProps) { + const t = useTranslations('aiopsTimeline') + const [expanded, setExpanded] = useState(true) + + const sev = incident.severity + const sevCfg = SEVERITY_CONFIG[sev] ?? SEVERITY_CONFIG.P3 + + const successCount = incident.stages.filter(s => s.status === 'success').length + const totalStages = incident.stages.length + + // 計算持續時間 + const durationLabel = useMemo(() => { + if (!incident.resolved_at) return t('incident.in_progress') + const ms = new Date(incident.resolved_at).getTime() - new Date(incident.started_at).getTime() + if (ms < 60000) return `${Math.round(ms / 1000)}s` + return `${Math.round(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s` + }, [incident, t]) + + // 格式化日期時間 + const startedLabel = useMemo(() => { + try { + return new Date(incident.started_at).toLocaleString('zh-TW', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + } catch { + return incident.started_at + } + }, [incident.started_at]) + + // 按 STAGE_ORDER 排序,補齊缺失階段 + const sortedStages = useMemo(() => { + return STAGE_ORDER.map(stageType => { + const entry = incident.stages.find(s => s.stage === stageType) + return entry ?? null + }) + }, [incident.stages]) + + return ( +
+ {/* Incident header card */} +
+ {/* Severity stripe */} +
+ + {/* Incident summary */} +
setExpanded(!expanded)} + role="button" + aria-expanded={expanded} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setExpanded(!expanded) + } + }} + > + {/* Severity badge */} + + {sev} + + + {/* Title + meta */} +
+

+ {incident.title} +

+
+ + {incident.incident_id} + + · + + {startedLabel} + + · + + {durationLabel} + +
+
+ + {/* Progress summary + toggle */} +
+ {/* Mini progress bar */} +
+ + {t('incident.stages_summary', { success: successCount, total: totalStages })} + +
+
+
+
+ {expanded + ? + : + } +
+
+
+ + {/* Timeline stages (展開時顯示) */} + {expanded && ( +
+ {/* Vertical rail */} +
+ {/* 主軌道線 — 粗,貫穿整個 timeline */} +
+ ) +} + +// ============================================================ +// Main Panel +// ============================================================ + +export default function AiopsTimelinePanel() { + const t = useTranslations('aiopsTimeline') + + const [filter, setFilter] = useState({ + incident_id: '', + time_range: '24h', + status_filter: 'all', + }) + + const handleFilterChange = useCallback((updated: Partial) => { + setFilter(prev => ({ ...prev, ...updated })) + }, []) + + // React Query — 真實 API + const { + data: apiData, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['aiops-timeline', filter.incident_id, filter.time_range], + queryFn: () => fetchTimeline(filter.incident_id), + enabled: !IS_MOCK, + staleTime: 30_000, + refetchInterval: 60_000, + }) + + // 客戶端篩選 — rawIncidents 內聯進 useMemo 避免 conditional 依賴警告 + const incidents = useMemo(() => { + const rawIncidents: TimelineIncident[] = IS_MOCK ? MOCK_INCIDENTS : (apiData ?? []) + let list = rawIncidents + + if (filter.incident_id.trim()) { + const q = filter.incident_id.toLowerCase() + list = list.filter( + inc => inc.incident_id.toLowerCase().includes(q) || inc.title.toLowerCase().includes(q) + ) + } + + if (filter.status_filter !== 'all') { + list = list.filter(inc => { + if (filter.status_filter === 'success') { + return inc.stages.every(s => s.status === 'success') + } + if (filter.status_filter === 'failed') { + return inc.stages.some(s => s.status === 'failed') + } + if (filter.status_filter === 'running') { + return inc.stages.some(s => s.status === 'running') + } + return true + }) + } + + return list + }, [apiData, filter.incident_id, filter.status_filter]) + + // ---- Render ---- + + return ( +
+ {/* Page header */} +
+
+

+

+

+ {t('subtitle')} +

+
+ + {/* Refresh button (僅真實模式顯示) */} + {!IS_MOCK && ( + + )} +
+ + {/* Filter bar */} + + + {/* Loading state (真實 API) */} + {!IS_MOCK && isLoading && ( +
+ + {t('loading')} +
+ )} + + {/* Error state */} + {!IS_MOCK && error && !isLoading && ( +
+ +

{t('error.title')}

+ +
+ )} + + {/* Empty state */} + {!isLoading && !error && incidents.length === 0 && ( +
+
+ )} + + {/* Timeline list */} + {incidents.length > 0 && ( +
+ {incidents.map((incident, i) => ( +
+ +
+ ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/aiops/timeline/EvidenceViewer.tsx b/apps/web/src/components/aiops/timeline/EvidenceViewer.tsx new file mode 100644 index 00000000..5c256b79 --- /dev/null +++ b/apps/web/src/components/aiops/timeline/EvidenceViewer.tsx @@ -0,0 +1,144 @@ +'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 ( +
+ {/* Tab nav — 8 個 dimension 可滾動 */} +
+ {dimensions.map((dim, i) => { + const cfg = DIMENSION_STATUS_CONFIG[dim.status] + return ( + + ) + })} +
+ + {/* Active dimension detail */} + {active && ( +
+
+
+
+ {(() => { + const cfg = DIMENSION_STATUS_CONFIG[active.status] + return ( + <> + + + {cfg.label} + + + ) + })()} +
+

+ {active.name} +

+ {active.detail && ( +

+ {active.detail} +

+ )} +
+
+ + {active.value !== null && active.value !== undefined ? String(active.value) : t('evidence.noData')} + +
+
+
+ )} + + {/* Summary row: anomaly count */} +
+ + {t('evidence.anomalyCount', { + count: dimensions.filter(d => d.status === 'anomaly').length, + total: dimensions.length, + })} + + {/* Mini status dots */} +
+ {dimensions.map((d, i) => ( +
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/aiops/timeline/TimelineFilter.tsx b/apps/web/src/components/aiops/timeline/TimelineFilter.tsx new file mode 100644 index 00000000..49b824e4 --- /dev/null +++ b/apps/web/src/components/aiops/timeline/TimelineFilter.tsx @@ -0,0 +1,109 @@ +'use client' + +// 2026-04-26 P2.5 by Claude — AIOps Timeline +// ============================================================ +// TimelineFilter — 篩選器元件 +// incident_id 搜尋 + 時間範圍 + 狀態過濾 +// ============================================================ + +import { useTranslations } from 'next-intl' +import { Search, Clock, Filter } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TimelineFilterState } from './types' + +interface TimelineFilterProps { + filter: TimelineFilterState + onChange: (updated: Partial) => void + incidentCount: number +} + +const TIME_RANGES: Array<{ value: TimelineFilterState['time_range']; labelKey: string }> = [ + { value: '1h', labelKey: '1h' }, + { value: '6h', labelKey: '6h' }, + { value: '24h', labelKey: '24h' }, + { value: '7d', labelKey: '7d' }, +] + +const STATUS_FILTERS: Array<{ value: TimelineFilterState['status_filter']; labelKey: string }> = [ + { value: 'all', labelKey: 'all' }, + { value: 'success', labelKey: 'success' }, + { value: 'failed', labelKey: 'failed' }, + { value: 'running', labelKey: 'running' }, +] + +export function TimelineFilter({ filter, onChange, incidentCount }: TimelineFilterProps) { + const t = useTranslations('aiopsTimeline') + + return ( +
+ {/* Incident ID 搜尋 */} +
+ + onChange({ incident_id: e.target.value })} + placeholder={t('filters.incident_id_placeholder')} + className={cn( + 'w-full pl-9 pr-3 py-2 rounded-lg', + 'bg-white border border-nothing-gray-200', + 'font-body text-sm text-nothing-black', + 'placeholder:text-nothing-gray-400', + 'focus:outline-none focus:ring-2 focus:ring-claw-blue/30 focus:border-claw-blue', + 'transition-colors' + )} + aria-label={t('filters.incident_id')} + /> +
+ + {/* 時間範圍 */} +
+
+ + {/* 狀態過濾 */} +
+
+ + {/* 結果計數 */} +
+ {t('filters.incident_count', { count: incidentCount })} +
+
+ ) +} diff --git a/apps/web/src/components/aiops/timeline/TimelineStage.tsx b/apps/web/src/components/aiops/timeline/TimelineStage.tsx new file mode 100644 index 00000000..62e0a508 --- /dev/null +++ b/apps/web/src/components/aiops/timeline/TimelineStage.tsx @@ -0,0 +1,279 @@ +'use client' + +// 2026-04-26 P2.5 by Claude — AIOps Timeline +// ============================================================ +// TimelineStage — 單階段 Card +// 顯示階段圖示 / 狀態 / 時間戳 / 摘要 +// 點擊展開 TimelineStageDetails +// ============================================================ + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { + CircleAlert, + Search, + Brain, + Zap, + CheckCircle2, + GraduationCap, + ChevronDown, + ChevronRight, + Clock, + SkipForward, + Loader2, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { TimelineStageDetails } from './TimelineStageDetails' +import type { StageType, StageStatus, StageData } from './types' + +// ----- Config ----- + +interface StageConfig { + Icon: typeof CircleAlert + accentClass: string // border-left色 + iconBg: string // icon 背景色 + iconColor: string // icon 前景色 + labelKey: string +} + +const STAGE_CONFIG: Record = { + alert: { + Icon: CircleAlert, + accentClass: 'border-l-status-critical', + iconBg: 'bg-status-critical/10', + iconColor: 'text-status-critical', + labelKey: 'alert', + }, + diagnose: { + Icon: Search, + accentClass: 'border-l-claw-blue', + iconBg: 'bg-claw-blue/10', + iconColor: 'text-claw-blue', + labelKey: 'diagnose', + }, + decide: { + Icon: Brain, + accentClass: 'border-l-status-thinking', + iconBg: 'bg-status-thinking/10', + iconColor: 'text-status-thinking', + labelKey: 'decide', + }, + execute: { + Icon: Zap, + accentClass: 'border-l-status-warning', + iconBg: 'bg-status-warning/10', + iconColor: 'text-status-warning', + labelKey: 'execute', + }, + verify: { + Icon: CheckCircle2, + accentClass: 'border-l-status-healthy', + iconBg: 'bg-status-healthy/10', + iconColor: 'text-status-healthy', + labelKey: 'verify', + }, + learn: { + Icon: GraduationCap, + accentClass: 'border-l-nothing-gray-400', + iconBg: 'bg-nothing-gray-100', + iconColor: 'text-nothing-gray-600', + labelKey: 'learn', + }, +} + +const STATUS_CONFIG: Record = { + success: { + badge: 'bg-status-healthy/10 border border-status-healthy/20', + badgeText: 'text-status-healthy', + labelKey: 'success', + }, + running: { + badge: 'bg-status-syncing/10 border border-status-syncing/20', + badgeText: 'text-status-syncing', + labelKey: 'running', + }, + failed: { + badge: 'bg-status-critical/10 border border-status-critical/20', + badgeText: 'text-status-critical', + labelKey: 'failed', + }, + skipped: { + badge: 'bg-nothing-gray-100 border border-nothing-gray-200', + badgeText: 'text-nothing-gray-400', + labelKey: 'skipped', + }, + pending: { + badge: 'bg-nothing-gray-50 border border-nothing-gray-200', + badgeText: 'text-nothing-gray-400', + labelKey: 'pending', + }, +} + +// ----- Helpers ----- + +function formatTs(iso: string): string { + try { + return new Date(iso).toLocaleTimeString('zh-TW', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + } catch { + return iso + } +} + +function formatDuration(ms?: number): string | null { + if (ms === undefined || ms === null || ms === 0) return null + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +// ----- Status indicator (left-most column) ----- + +function StatusDot({ status }: { status: StageStatus }) { + if (status === 'running') { + return + } + if (status === 'skipped') { + return + } + const colorMap: Record = { + success: 'bg-status-healthy shadow-[0_0_6px_rgba(34,197,94,0.4)]', + failed: 'bg-status-critical shadow-[0_0_6px_rgba(255,51,0,0.4)]', + running: 'bg-status-syncing', + skipped: 'bg-nothing-gray-300', + pending: 'bg-nothing-gray-200', + } + return ( +
+ ) +} + +// ----- Main component ----- + +interface TimelineStageProps { + stage: StageType + status: StageStatus + timestamp: string + duration_ms?: number + data: StageData + /** CSS 動畫延遲(階梯式入場)*/ + animationDelay?: number + /** 是否跳過此階段(顯示灰色) */ + isSkipped?: boolean +} + +export function TimelineStage({ + stage, + status, + timestamp, + duration_ms, + data, + animationDelay = 0, + isSkipped = false, +}: TimelineStageProps) { + const t = useTranslations('aiopsTimeline') + const [expanded, setExpanded] = useState(false) + + const cfg = STAGE_CONFIG[stage] + const stCfg = STATUS_CONFIG[status] + const Icon = cfg.Icon + const dur = formatDuration(duration_ms) + + const canExpand = status !== 'pending' && status !== 'skipped' + + return ( +
+ {/* Card */} +
canExpand && setExpanded(!expanded)} + role={canExpand ? 'button' : undefined} + tabIndex={canExpand ? 0 : undefined} + aria-expanded={canExpand ? expanded : undefined} + aria-label={canExpand ? t('stage.toggle_details', { stage: t(`stages.${cfg.labelKey}`) }) : undefined} + onKeyDown={(e) => { + if (canExpand && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + setExpanded(!expanded) + } + }} + > + {/* Header Row */} +
+ {/* Stage icon */} +
+
+ + {/* Stage label + status */} +
+
+ + {t(`stages.${cfg.labelKey}`)} + + + {t(`status.${stCfg.labelKey}`)} + +
+ + {/* Timestamp + duration */} +
+
+
+ + {/* Status dot + chevron */} +
+ + {canExpand && ( +
+ +
+ )} + {!canExpand && } +
+
+ + {/* Expanded details */} + {expanded && canExpand && ( +
+
+ +
+
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/aiops/timeline/TimelineStageDetails.tsx b/apps/web/src/components/aiops/timeline/TimelineStageDetails.tsx new file mode 100644 index 00000000..99a7e953 --- /dev/null +++ b/apps/web/src/components/aiops/timeline/TimelineStageDetails.tsx @@ -0,0 +1,359 @@ +'use client' + +// 2026-04-26 P2.5 by Claude — AIOps Timeline +// ============================================================ +// TimelineStageDetails — 展開的詳情區塊 +// 每個 stage 有自己的展開格式 +// ============================================================ + +import { useTranslations } from 'next-intl' +import { EvidenceViewer } from './EvidenceViewer' +import { cn } from '@/lib/utils' +import type { + StageType, + StageData, + AlertStageData, + DiagnoseStageData, + DecideStageData, + ExecuteStageData, + VerifyStageData, + LearnStageData, +} from './types' + +interface TimelineStageDetailsProps { + stage: StageType + data: StageData +} + +// ----- Helper Components ----- + +function MetaRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { + return ( +
+ + {label} + + + {value} + +
+ ) +} + +function LabelBadge({ label, value }: { label: string; value: string }) { + return ( +
+ {label}: + {value} +
+ ) +} + +function ConfidenceBar({ value, threshold }: { value: number; threshold: number }) { + const t = useTranslations('aiopsTimeline') + const pct = Math.min(100, Math.round(value * 100)) + const isAbove = value >= threshold + + return ( +
+
+
+
+
+ + {pct}% + + + {t('stageDetails.decide.confidenceThreshold', { value: Math.round(threshold * 100) })} + +
+
+ ) +} + +function TrustBar({ before, after }: { before: number; after: number }) { + const delta = after - before + return ( +
+ {(before * 100).toFixed(0)}% +
+
+
+
+ + {(after * 100).toFixed(0)}% + +
+ ) +} + +// ----- Stage-specific renderers ----- + +function AlertDetails({ data }: { data: AlertStageData }) { + const t = useTranslations('aiopsTimeline') + return ( +
+ + {data.rule_matched}} /> + {data.raw_value && ( + {data.raw_value} + {data.threshold && / {data.threshold}} + + } + /> + )} + {Object.keys(data.labels).length > 0 && ( +
+ + {t('stageDetails.alert.labels')} + +
+ {Object.entries(data.labels).map(([k, v]) => ( + + ))} +
+
+ )} +
+ ) +} + +function DiagnoseDetails({ data }: { data: DiagnoseStageData }) { + const t = useTranslations('aiopsTimeline') + return ( +
+ +
+ + {t('stageDetails.diagnose.tools_used')} + +
+ {data.mcp_tools_used.map((tool) => ( + + {tool} + + ))} +
+
+ +
+ + {t('stageDetails.diagnose.evidence')} + + +
+
+ ) +} + +function DecideDetails({ data }: { data: DecideStageData }) { + const t = useTranslations('aiopsTimeline') + return ( +
+ + +
+ + {t('stageDetails.decide.confidence')} + + +
+ + {data.auto_execute ? t('stageDetails.decide.auto_yes') : t('stageDetails.decide.auto_no')} + + } + /> + {data.playbook_name} ({data.playbook_id}) + } /> +
+ + {t('stageDetails.decide.decision')} + +

+ {data.decision} +

+
+
+ + {t('stageDetails.decide.reasoning')} + +

+ {data.reasoning} +

+
+ {data.alternate_decisions && data.alternate_decisions.length > 0 && ( +
+ + {t('stageDetails.decide.alternates')} + +
+ {data.alternate_decisions.map((alt, i) => ( +
+
+
+
+ {alt.decision} + {(alt.confidence * 100).toFixed(0)}% +
+ ))} +
+
+ )} +
+ ) +} + +function ExecuteDetails({ data }: { data: ExecuteStageData }) { + const t = useTranslations('aiopsTimeline') + return ( +
+ + + +
+ + {t('stageDetails.execute.command')} + +
+          {data.command}
+        
+
+ {data.stdout && ( +
+ + {t('stageDetails.execute.stdout')} + +
+            {data.stdout}
+          
+
+ )} + + {data.exit_code} + + } + /> +
+ ) +} + +function VerifyDetails({ data }: { data: VerifyStageData }) { + const t = useTranslations('aiopsTimeline') + const pct = Math.round((data.checks_passed / data.checks_total) * 100) + return ( +
+ + + {data.outcome} + + } + /> +
+ + {t('stageDetails.verify.checks')} + +
+
+
+
+ + {data.checks_passed}/{data.checks_total} + +
+
+ {data.trust_delta !== 0 && ( + 0 ? 'text-status-healthy' : 'text-status-critical')}> + {data.trust_delta > 0 ? '+' : ''}{(data.trust_delta * 100).toFixed(0)}% + + } + /> + )} + {data.notes && ( + + )} +
+ ) +} + +function LearnDetails({ data }: { data: LearnStageData }) { + const t = useTranslations('aiopsTimeline') + return ( +
+ {data.playbook_id}} /> +
+ + {t('stageDetails.learn.trust_update')} + +
+ +
+
+ {data.km_entry_id && ( + {data.km_entry_id}} /> + )} + +
+ ) +} + +// ----- Main export ----- + +export function TimelineStageDetails({ stage, data }: TimelineStageDetailsProps) { + switch (stage) { + case 'alert': return + case 'diagnose': return + case 'decide': return + case 'execute': return + case 'verify': return + case 'learn': return + default: return null + } +} diff --git a/apps/web/src/components/aiops/timeline/index.ts b/apps/web/src/components/aiops/timeline/index.ts new file mode 100644 index 00000000..70675545 --- /dev/null +++ b/apps/web/src/components/aiops/timeline/index.ts @@ -0,0 +1,7 @@ +// 2026-04-26 P2.5 by Claude — AIOps Timeline +export { TimelineStage } from './TimelineStage' +export { TimelineStageDetails } from './TimelineStageDetails' +export { TimelineFilter } from './TimelineFilter' +export { EvidenceViewer } from './EvidenceViewer' +export { MOCK_INCIDENTS } from './mock-data' +export type * from './types' diff --git a/apps/web/src/components/aiops/timeline/mock-data.ts b/apps/web/src/components/aiops/timeline/mock-data.ts new file mode 100644 index 00000000..8f3c678f --- /dev/null +++ b/apps/web/src/components/aiops/timeline/mock-data.ts @@ -0,0 +1,357 @@ +// 2026-04-26 P2.5 by Claude — AIOps Timeline +// ============================================================ +// Mock Data — 3 範例 incident,完整 6 階段 + metadata +// NEXT_PUBLIC_AIOPS_TIMELINE_MOCK=true 時使用 +// ============================================================ + +import type { TimelineIncident } from './types' + +export const MOCK_INCIDENTS: TimelineIncident[] = [ + { + incident_id: 'INC-2026-0425-001', + title: 'API Server CPU 100% — k8s-188', + severity: 'P0', + started_at: '2026-04-25T14:22:03+08:00', + resolved_at: '2026-04-25T14:22:55+08:00', + stages: [ + { + stage: 'alert', + status: 'success', + timestamp: '2026-04-25T14:22:03+08:00', + duration_ms: 0, + data: { + alert_name: 'HighCPUUsage', + severity: 'P0', + rule_matched: 'cpu-burst-rule-v3', + labels: { + host: 'k8s-188', + service: 'awoooi-api', + namespace: 'production', + }, + annotations: { + summary: 'CPU 使用率超過 98% 持續 5 分鐘', + runbook_url: 'https://wiki.internal/runbooks/high-cpu', + }, + raw_value: '100%', + threshold: '95%', + }, + }, + { + stage: 'diagnose', + status: 'success', + timestamp: '2026-04-25T14:22:04+08:00', + duration_ms: 8240, + data: { + investigator: 'PreDecisionInvestigator v2.1', + mcp_tools_used: [ + 'kubectl_top_pods', + 'kubectl_describe_pod', + 'prometheus_query_range', + 'loki_logs_tail', + 'pg_active_queries', + ], + dimensions: [ + { name: 'CPU 使用率', key: 'cpu_usage', value: '100%', status: 'anomaly', detail: 'P99 持續 5m 超限' }, + { name: '記憶體', key: 'memory', value: '68%', status: 'ok', detail: '正常範圍內' }, + { name: 'Pod 狀態', key: 'pod_status', value: 'Running', status: 'ok', detail: '2/2 Ready' }, + { name: '請求速率', key: 'req_rate', value: '4200/s', status: 'anomaly', detail: '正常值 800/s,異常 5×' }, + { name: '錯誤率', key: 'error_rate', value: '0.2%', status: 'ok', detail: '< 1% SLO 達標' }, + { name: '最近部署', key: 'last_deploy', value: '4h ago', status: 'ok', detail: 'v2.8.1 穩定' }, + { name: 'DB 查詢', key: 'db_queries', value: 12.4, status: 'anomaly', detail: '平均 12.4ms(基準 2ms)' }, + { name: 'GC 壓力', key: 'gc_pressure', value: '45ms/s', status: 'anomaly', detail: 'Full GC 觸發率異常' }, + ], + summary: '請求速率異常飆升(5×正常值),觸發 CPU 飽和與 GC 壓力', + root_cause_hypothesis: '流量突增導致 CPU 飽和,推測為外部爬蟲或流量攻擊,Pod 重啟可短暫緩解', + }, + }, + { + stage: 'decide', + status: 'success', + timestamp: '2026-04-25T14:22:12+08:00', + duration_ms: 1340, + data: { + engine: 'OpenClaw v3.2', + fusion_method: 'Weighted Confidence Fusion III', + confidence: 0.87, + threshold_used: 0.75, + auto_execute: true, + decision: 'kubectl rollout restart deployment/awoooi-api', + reasoning: '8D 證據確認為暫態流量突增導致的 CPU 飽和;重啟可清除 GC 積壓,同時觸發 HPA 橫向擴展。信心度 0.87 > 自動執行門檻 0.75。', + playbook_id: 'PB-K8S-001', + playbook_name: 'K8s Deployment Restart — CPU High', + alternate_decisions: [ + { decision: 'kubectl scale deployment/awoooi-api --replicas=4', confidence: 0.61 }, + { decision: 'human_approval_required', confidence: 0.13 }, + ], + }, + }, + { + stage: 'execute', + status: 'success', + timestamp: '2026-04-25T14:22:13+08:00', + duration_ms: 12400, + data: { + command: 'kubectl rollout restart deployment/awoooi-api -n production', + target: 'deployment/awoooi-api @ k8s-188', + executor: 'AutoExecutor v2.0', + duration_ms: 12400, + stdout: 'deployment.apps/awoooi-api restarted\nWaiting for rollout to finish: 0 of 2 updated replicas are available...\nWaiting for rollout to finish: 1 of 2 updated replicas are available...\ndeployment "awoooi-api" successfully rolled out', + exit_code: 0, + }, + }, + { + stage: 'verify', + status: 'success', + timestamp: '2026-04-25T14:22:26+08:00', + duration_ms: 29000, + data: { + verifier: 'PostExecutionVerifier v1.4', + outcome: 'SUCCESS', + checks_passed: 5, + checks_total: 5, + trust_delta: 0.05, + notes: 'CPU 回落至 42%,所有 Pod Ready,SLO 指標正常', + }, + }, + { + stage: 'learn', + status: 'success', + timestamp: '2026-04-25T14:22:55+08:00', + duration_ms: 210, + data: { + playbook_id: 'PB-K8S-001', + trust_before: 0.50, + trust_after: 0.55, + km_entry_id: 'KM-20260425-0312', + learning_summary: 'Playbook PB-K8S-001 信任度 +0.05(0.50 → 0.55)。成功處置 CPU 突增事件,記錄至知識庫。', + }, + }, + ], + }, + { + incident_id: 'INC-2026-0424-007', + title: 'PostgreSQL 備份失敗 — host-110', + severity: 'P1', + started_at: '2026-04-24T03:15:00+08:00', + resolved_at: '2026-04-24T03:21:44+08:00', + stages: [ + { + stage: 'alert', + status: 'success', + timestamp: '2026-04-24T03:15:00+08:00', + duration_ms: 0, + data: { + alert_name: 'HostBackupFailed', + severity: 'P1', + rule_matched: 'backup-age-rule-v2', + labels: { host: 'host-110', job: 'pg-backup-cron' }, + annotations: { summary: '備份任務超過 25 小時未成功' }, + raw_value: '26h ago', + threshold: '25h', + }, + }, + { + stage: 'diagnose', + status: 'success', + timestamp: '2026-04-24T03:15:01+08:00', + duration_ms: 6800, + data: { + investigator: 'PreDecisionInvestigator v2.1', + mcp_tools_used: [ + 'ssh_exec', + 'pg_backup_status', + 'disk_usage_check', + 'cron_log_tail', + ], + dimensions: [ + { name: '備份年齡', key: 'backup_age', value: '26h', status: 'anomaly', detail: '超過 25h 門檻' }, + { name: '磁碟空間', key: 'disk_free', value: '2.1GB', status: 'anomaly', detail: '備份目錄空間不足' }, + { name: 'PG 狀態', key: 'pg_status', value: 'running', status: 'ok', detail: 'PostgreSQL 正常運行' }, + { name: 'Cron 日誌', key: 'cron_log', value: 'FAILED', status: 'anomaly', detail: 'No space left on device' }, + { name: '上次備份', key: 'last_backup', value: null, status: 'unknown', detail: '無法取得' }, + { name: '網路', key: 'network', value: 'ok', status: 'ok', detail: 'Remote host 可達' }, + { name: '備份腳本', key: 'script_status', value: 'exist', status: 'ok', detail: '/opt/backup/pg_backup.sh 存在' }, + { name: '排程狀態', key: 'cron_enabled', value: 'enabled', status: 'ok', detail: 'Cron job 已啟用' }, + ], + summary: '備份目錄磁碟空間不足(僅剩 2.1GB),導致備份任務失敗', + root_cause_hypothesis: '磁碟空間耗盡,需清理舊備份後重新執行', + }, + }, + { + stage: 'decide', + status: 'success', + timestamp: '2026-04-24T03:15:08+08:00', + duration_ms: 890, + data: { + engine: 'OpenClaw v3.2', + fusion_method: 'Weighted Confidence Fusion III', + confidence: 0.91, + threshold_used: 0.75, + auto_execute: true, + decision: 'cleanup_old_backups && trigger_pg_backup', + reasoning: '磁碟空間不足是明確根因,清理 7 天前備份後重新執行可解決。信心度 0.91 高於門檻。', + playbook_id: 'PB-BACKUP-001', + playbook_name: 'Backup Disk Cleanup & Retry', + alternate_decisions: [ + { decision: 'human_approval_required', confidence: 0.09 }, + ], + }, + }, + { + stage: 'execute', + status: 'success', + timestamp: '2026-04-24T03:15:09+08:00', + duration_ms: 394000, + data: { + command: 'find /backup -name "*.sql.gz" -mtime +7 -delete && /opt/backup/pg_backup.sh', + target: 'host-110 via SSH', + executor: 'AutoExecutor v2.0', + duration_ms: 394000, + stdout: 'Cleaned 8 files (12.3GB freed)\nStarting PostgreSQL backup...\nBackup completed: /backup/pg_2026-04-24_031509.sql.gz (3.2GB)', + exit_code: 0, + }, + }, + { + stage: 'verify', + status: 'success', + timestamp: '2026-04-24T03:21:32+08:00', + duration_ms: 12000, + data: { + verifier: 'PostExecutionVerifier v1.4', + outcome: 'SUCCESS', + checks_passed: 4, + checks_total: 4, + trust_delta: 0.05, + notes: '備份檔案存在且大小合理,磁碟空間恢復正常(14.4GB 可用)', + }, + }, + { + stage: 'learn', + status: 'success', + timestamp: '2026-04-24T03:21:44+08:00', + duration_ms: 180, + data: { + playbook_id: 'PB-BACKUP-001', + trust_before: 0.65, + trust_after: 0.70, + km_entry_id: 'KM-20260424-0089', + learning_summary: 'Playbook PB-BACKUP-001 信任度 +0.05(0.65 → 0.70)。建議增加磁碟空間預警規則。', + }, + }, + ], + }, + { + incident_id: 'INC-2026-0423-015', + title: 'Cadvisor OOM — monitoring namespace', + severity: 'P2', + started_at: '2026-04-23T09:41:22+08:00', + stages: [ + { + stage: 'alert', + status: 'success', + timestamp: '2026-04-23T09:41:22+08:00', + duration_ms: 0, + data: { + alert_name: 'ContainerOOMKilled', + severity: 'P2', + rule_matched: 'oom-kill-detection-v1', + labels: { + container: 'cadvisor', + namespace: 'monitoring', + node: 'k8s-188', + }, + annotations: { summary: 'cadvisor 被 OOM Killer 終止' }, + raw_value: 'OOMKilled', + }, + }, + { + stage: 'diagnose', + status: 'success', + timestamp: '2026-04-23T09:41:23+08:00', + duration_ms: 5400, + data: { + investigator: 'PreDecisionInvestigator v2.1', + mcp_tools_used: [ + 'kubectl_describe_pod', + 'kubectl_top_pods', + 'prometheus_memory_usage', + ], + dimensions: [ + { name: 'OOM 事件', key: 'oom_event', value: 'OOMKilled', status: 'anomaly', detail: 'cadvisor 觸發 OOM' }, + { name: 'Memory limit', key: 'mem_limit', value: '256Mi', status: 'anomaly', detail: '限制過低' }, + { name: 'Memory 實際', key: 'mem_actual', value: '260Mi+', status: 'anomaly', detail: '超過 limit' }, + { name: 'CPU', key: 'cpu', value: '288%', status: 'anomaly', detail: '已持續異常 13 天(監控盲區)' }, + { name: 'Pod 重啟', key: 'restart_count', value: 47, status: 'anomaly', detail: '47 次重啟' }, + { name: '影響範圍', key: 'blast_radius', value: '監控層', status: 'anomaly', detail: '指標採集中斷' }, + { name: '其他 Pod', key: 'other_pods', value: '正常', status: 'ok', detail: '其餘 monitoring pod 正常' }, + { name: '節點資源', key: 'node_resources', value: '充裕', status: 'ok', detail: '節點整體資源正常' }, + ], + summary: 'cadvisor memory limit 設定過低(256Mi),實際需求超過限制導致 OOM', + root_cause_hypothesis: '提高 cadvisor memory limit 至 512Mi 並重啟', + }, + }, + { + stage: 'decide', + status: 'success', + timestamp: '2026-04-23T09:41:29+08:00', + duration_ms: 1200, + data: { + engine: 'OpenClaw v3.2', + fusion_method: 'Weighted Confidence Fusion III', + confidence: 0.78, + threshold_used: 0.75, + auto_execute: true, + decision: 'kubectl patch daemonset cadvisor -p memory_limit=512Mi', + reasoning: '根因明確(memory limit 過低),提升 limit 後重啟即可。影響範圍僅監控層,無業務風險。', + playbook_id: 'PB-K8S-OOM-001', + playbook_name: 'K8s OOM — Increase Memory Limit', + alternate_decisions: [ + { decision: 'kubectl delete pod cadvisor (restart only)', confidence: 0.20 }, + { decision: 'human_approval_required', confidence: 0.02 }, + ], + }, + }, + { + stage: 'execute', + status: 'success', + timestamp: '2026-04-23T09:41:30+08:00', + duration_ms: 8300, + data: { + command: 'kubectl patch daemonset cadvisor -n monitoring --type merge -p \'{"spec":{"template":{"spec":{"containers":[{"name":"cadvisor","resources":{"limits":{"memory":"512Mi"}}}]}}}}\'', + target: 'daemonset/cadvisor @ monitoring', + executor: 'AutoExecutor v2.0', + duration_ms: 8300, + stdout: 'daemonset.apps/cadvisor patched\ncadvisor-xk2p9 restarted', + exit_code: 0, + }, + }, + { + stage: 'verify', + status: 'success', + timestamp: '2026-04-23T09:41:39+08:00', + duration_ms: 45000, + data: { + verifier: 'PostExecutionVerifier v1.4', + outcome: 'SUCCESS', + checks_passed: 3, + checks_total: 4, + trust_delta: 0.03, + notes: 'cadvisor Running,OOM 事件消除。CPU 仍 220%(長期問題,已記錄待 P2.6 處理)', + }, + }, + { + stage: 'learn', + status: 'success', + timestamp: '2026-04-23T09:42:24+08:00', + duration_ms: 160, + data: { + playbook_id: 'PB-K8S-OOM-001', + trust_before: 0.40, + trust_after: 0.43, + km_entry_id: 'KM-20260423-0156', + learning_summary: 'Playbook PB-K8S-OOM-001 信任度 +0.03(0.40 → 0.43)。cadvisor CPU 問題已記錄為待處理項目。', + }, + }, + ], + }, +] diff --git a/apps/web/src/components/aiops/timeline/types.ts b/apps/web/src/components/aiops/timeline/types.ts new file mode 100644 index 00000000..d83c348f --- /dev/null +++ b/apps/web/src/components/aiops/timeline/types.ts @@ -0,0 +1,118 @@ +// 2026-04-26 P2.5 by Claude — AIOps Timeline +// ============================================================ +// AIOps Timeline 型別定義 +// 告警→決策→執行→驗證鏈路資料結構 +// ============================================================ + +export type StageStatus = 'success' | 'running' | 'failed' | 'skipped' | 'pending' + +export type StageType = + | 'alert' + | 'diagnose' + | 'decide' + | 'execute' + | 'verify' + | 'learn' + +// ----- Alert Stage ----- +export interface AlertStageData { + alert_name: string + severity: 'P0' | 'P1' | 'P2' | 'P3' + rule_matched: string + labels: Record + annotations: Record + raw_value?: string + threshold?: string +} + +// ----- Diagnose Stage ----- +export interface EvidenceDimension { + name: string + key: string + value: string | number | null + status: 'ok' | 'anomaly' | 'unknown' + detail?: string +} + +export interface DiagnoseStageData { + investigator: string + mcp_tools_used: string[] + dimensions: EvidenceDimension[] + summary: string + root_cause_hypothesis: string +} + +// ----- Decide Stage ----- +export interface DecideStageData { + engine: string + fusion_method: string + confidence: number + threshold_used: number + auto_execute: boolean + decision: string + reasoning: string + playbook_id: string + playbook_name: string + alternate_decisions?: Array<{ decision: string; confidence: number }> +} + +// ----- Execute Stage ----- +export interface ExecuteStageData { + command: string + target: string + executor: string + duration_ms: number + stdout?: string + stderr?: string + exit_code: number +} + +// ----- Verify Stage ----- +export interface VerifyStageData { + verifier: string + outcome: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'TIMEOUT' + checks_passed: number + checks_total: number + trust_delta: number + notes?: string +} + +// ----- Learn Stage ----- +export interface LearnStageData { + playbook_id: string + trust_before: number + trust_after: number + km_entry_id?: string + learning_summary: string +} + +export type StageData = + | AlertStageData + | DiagnoseStageData + | DecideStageData + | ExecuteStageData + | VerifyStageData + | LearnStageData + +export interface TimelineStageEntry { + stage: StageType + status: StageStatus + timestamp: string // ISO 8601 + duration_ms?: number + data: StageData +} + +export interface TimelineIncident { + incident_id: string + title: string + severity: 'P0' | 'P1' | 'P2' | 'P3' + started_at: string + resolved_at?: string + stages: TimelineStageEntry[] +} + +export interface TimelineFilterState { + incident_id: string + time_range: '1h' | '6h' | '24h' | '7d' | 'custom' + status_filter: 'all' | 'success' | 'failed' | 'running' +}