Files
awoooi/apps/web/src/components/aiops/timeline/AiopsTimelinePanel.tsx
Your Name d5ce17c72d
All checks were successful
CD Pipeline / tests (push) Successful in 1m23s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 3m41s
CD Pipeline / post-deploy-checks (push) Successful in 2m7s
fix(web): 標示 AIOps 範例資料模式
2026-06-05 01:28:59 +08:00

415 lines
15 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
// ============================================================
// 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<TimelineIncident[]> {
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 (
<article
className="animate-slide-in-up"
style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }}
aria-label={incident.title}
>
{/* Incident header card */}
<div
className="bg-white rounded-card border border-nothing-gray-200 shadow-card mb-3 overflow-hidden"
>
{/* Severity stripe */}
<div className={cn('h-1', {
'bg-status-critical': sev === 'P0',
'bg-status-warning': sev === 'P1',
'bg-claw-blue': sev === 'P2',
'bg-nothing-gray-300': sev === 'P3',
})} />
{/* Incident summary */}
<div
className="flex items-start gap-3 p-4 cursor-pointer hover:bg-nothing-gray-50 transition-colors"
onClick={() => setExpanded(!expanded)}
role="button"
aria-expanded={expanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setExpanded(!expanded)
}
}}
>
{/* Severity badge */}
<span className={cn(
'flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded text-[11px] font-body font-bold uppercase mt-0.5',
sevCfg.bg, sevCfg.text, 'border', sevCfg.border
)}>
{sev}
</span>
{/* Title + meta */}
<div className="flex-1 min-w-0">
<p className="font-heading text-sm font-semibold text-nothing-black leading-tight">
{incident.title}
</p>
<div className="flex items-center gap-3 mt-1 flex-wrap">
<span className="font-body text-[11px] text-nothing-gray-400 font-mono">
{incident.incident_id}
</span>
<span className="text-nothing-gray-200">·</span>
<span className="font-body text-[11px] text-nothing-gray-400">
{startedLabel}
</span>
<span className="text-nothing-gray-200">·</span>
<span className="font-body text-[11px] text-nothing-gray-500 font-medium">
{durationLabel}
</span>
</div>
</div>
{/* Progress summary + toggle */}
<div className="flex items-center gap-3 flex-shrink-0">
{/* Mini progress bar */}
<div className="hidden sm:flex flex-col items-end gap-1">
<span className="text-[10px] font-body text-nothing-gray-400">
{t('incident.stages_summary', { success: successCount, total: totalStages })}
</span>
<div className="w-20 h-1 bg-nothing-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-status-healthy rounded-full"
style={{ width: `${(successCount / totalStages) * 100}%` }}
/>
</div>
</div>
{expanded
? <ChevronUp className="w-4 h-4 text-nothing-gray-400" />
: <ChevronDown className="w-4 h-4 text-nothing-gray-400" />
}
</div>
</div>
</div>
{/* Timeline stages (展開時顯示) */}
{expanded && (
<div className="pl-4 sm:pl-8 mb-6">
{/* Vertical rail */}
<div className="relative">
{/* 主軌道線 — 粗,貫穿整個 timeline */}
<div
className="absolute left-0 top-4 bottom-4 w-0.5 bg-nothing-gray-200"
aria-hidden="true"
/>
<div className="space-y-2 pl-6">
{sortedStages.map((entry, i) => {
if (!entry) {
// 缺失階段:顯示佔位
const stageType = STAGE_ORDER[i]
return (
<div
key={stageType}
className="relative animate-fade-in"
style={{ animationDelay: `${(index * 80) + (i * 60)}ms`, animationFillMode: 'both' }}
>
{/* 連接點 */}
<div className="absolute -left-6 top-3 w-2 h-2 rounded-full bg-nothing-gray-200 border-2 border-nothing-gray-300" />
</div>
)
}
return (
<div
key={entry.stage}
className="relative"
>
{/* 軌道連接點 */}
<div
className={cn(
'absolute -left-6 top-3.5 w-2.5 h-2.5 rounded-full border-2',
entry.status === 'success' ? 'bg-status-healthy border-status-healthy/40'
: entry.status === 'failed' ? 'bg-status-critical border-status-critical/40'
: entry.status === 'running' ? 'bg-status-syncing border-status-syncing/40 animate-pulse'
: 'bg-nothing-gray-300 border-nothing-gray-200'
)}
aria-hidden="true"
/>
<TimelineStage
stage={entry.stage}
status={entry.status}
timestamp={entry.timestamp}
duration_ms={entry.duration_ms}
data={entry.data}
animationDelay={(index * 80) + (i * 60)}
/>
</div>
)
})}
</div>
</div>
</div>
)}
</article>
)
}
// ============================================================
// Main Panel
// ============================================================
export default function AiopsTimelinePanel() {
const t = useTranslations('aiopsTimeline')
const [filter, setFilter] = useState<TimelineFilterState>({
incident_id: '',
time_range: '24h',
status_filter: 'all',
})
const handleFilterChange = useCallback((updated: Partial<TimelineFilterState>) => {
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 (
<section aria-label={t('title')}>
{/* Page header */}
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h1 className="font-heading text-2xl font-bold text-nothing-black flex items-center gap-2.5">
<GitBranch className="w-6 h-6 text-claw-blue" aria-hidden="true" />
{t('title')}
{USE_SAMPLE_TIMELINE && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-body font-bold uppercase tracking-widest bg-status-warning/10 border border-status-warning/20 text-status-warning">
{t('sampleBadge')}
</span>
)}
</h1>
<p className="mt-1 text-sm font-body text-nothing-gray-500">
{t('subtitle')}
</p>
</div>
{/* Refresh button (僅真實模式顯示) */}
{!USE_SAMPLE_TIMELINE && (
<button
onClick={() => refetch()}
disabled={isLoading}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg flex-shrink-0',
'text-xs font-body bg-nothing-gray-100 text-nothing-gray-600',
'hover:bg-nothing-gray-200 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
aria-label={t('common.refresh')}
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</button>
)}
</div>
{/* Filter bar */}
<TimelineFilter
filter={filter}
onChange={handleFilterChange}
incidentCount={incidents.length}
/>
{/* Loading state (真實 API) */}
{!USE_SAMPLE_TIMELINE && isLoading && (
<div className="flex items-center justify-center py-20" role="status" aria-live="polite">
<RefreshCw className="w-5 h-5 animate-spin text-nothing-gray-400" />
<span className="ml-2 font-body text-sm text-nothing-gray-400">{t('loading')}</span>
</div>
)}
{/* Error state */}
{!USE_SAMPLE_TIMELINE && error && !isLoading && (
<div
className="flex flex-col items-center justify-center py-20 gap-3"
role="alert"
>
<AlertCircle className="w-10 h-10 text-status-critical" />
<p className="font-heading text-base font-semibold text-nothing-black">{t('error.title')}</p>
<button
onClick={() => refetch()}
className="px-4 py-2 rounded-lg bg-nothing-black text-white font-body text-sm hover:opacity-80 transition-opacity"
>
{t('error.retry')}
</button>
</div>
)}
{/* Empty state */}
{!isLoading && !error && incidents.length === 0 && (
<div
className="flex flex-col items-center justify-center py-20 gap-3"
role="status"
>
<InboxIcon className="w-10 h-10 text-nothing-gray-300" aria-hidden="true" />
<p className="font-heading text-base font-semibold text-nothing-gray-500">{t('empty.title')}</p>
<p className="font-body text-sm text-nothing-gray-400">{t('empty.subtitle')}</p>
</div>
)}
{/* Timeline list */}
{incidents.length > 0 && (
<div role="list" aria-label={t('title')}>
{incidents.map((incident, i) => (
<div key={incident.incident_id} role="listitem">
<IncidentCard incident={incident} index={i} />
</div>
))}
</div>
)}
</section>
)
}