'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 { SAMPLE_TIMELINE_INCIDENTS } from './sample-incidents' import type { TimelineIncident, TimelineFilterState, StageType, } from './types' // ============================================================ // Constants // ============================================================ // 保留既有環境變數相容;UI 以「範例資料」呈現,避免誤讀為正式證據。 const USE_SAMPLE_TIMELINE = 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: !USE_SAMPLE_TIMELINE, staleTime: 30_000, refetchInterval: 60_000, }) // 客戶端篩選 — rawIncidents 內聯進 useMemo 避免 conditional 依賴警告 const incidents = useMemo(() => { const rawIncidents: TimelineIncident[] = USE_SAMPLE_TIMELINE ? SAMPLE_TIMELINE_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 (僅真實模式顯示) */} {!USE_SAMPLE_TIMELINE && ( )}
{/* Filter bar */} {/* Loading state (真實 API) */} {!USE_SAMPLE_TIMELINE && isLoading && (
{t('loading')}
)} {/* Error state */} {!USE_SAMPLE_TIMELINE && error && !isLoading && (

{t('error.title')}

)} {/* Empty state */} {!isLoading && !error && incidents.length === 0 && (
)} {/* Timeline list */} {incidents.length > 0 && (
{incidents.map((incident, i) => (
))}
)}
) }