415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
'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>
|
||
)
|
||
}
|