'use client' /** * TicketsPanel — 工單追蹤面板 (不含 AppLayout) * ============================================== * Sprint 5: 從 /tickets/page.tsx 抽取 * 供原始頁面和整合頁面 (/operations) 共用 * * 2026-05-31 Codex: 接上 Incident status-chain / timeline,讓 Tickets 不再只是清單。 */ import { useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { ArrowRight, GitBranch, ListChecks, RefreshCw, SearchCheck, ShieldCheck, TriangleAlert, } from 'lucide-react' import { Link } from '@/i18n/routing' import { AwoooPStatusChainPanel, type AwoooPStatusChain, } from '@/components/awooop/status-chain' import { cn } from '@/lib/utils' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' interface Incident { incident_id?: string id?: string title?: string | null severity: string status: string signal_count?: number proposal_count?: number created_at: string updated_at?: string affected_services?: string[] affected_service?: string | null } interface IncidentListResponse { incidents: Incident[] count?: number total?: number } interface IncidentTimelineEvent { stage: string status: string title: string description?: string | null actor?: string | null timestamp?: string | null source_table?: string | null data?: Record } type IncidentTimelineStage = IncidentTimelineEvent & { label: string events?: IncidentTimelineEvent[] } interface IncidentTimelineResponse { incident_id: string title: string status: string severity: string started_at?: string | null updated_at?: string | null resolved_at?: string | null affected_services?: string[] approval_ids?: string[] timeline: IncidentTimelineStage[] events: IncidentTimelineEvent[] ascii_timeline: string } const SEV_COLOR: Record = { P0: '#cc2200', P1: '#F59E0B', P2: '#4A90D9', P3: '#22C55E', } const STATUS_COLOR: Record = { open: '#cc2200', investigating: '#F59E0B', in_progress: '#F59E0B', mitigating: '#4A90D9', resolved: '#22C55E', closed: '#87867f', } async function fetchJson(url: string, timeoutMs = 10_000): Promise { const controller = new AbortController() const timeout = window.setTimeout(() => controller.abort(), timeoutMs) try { const response = await fetch(url, { cache: 'no-store', signal: controller.signal, }) if (!response.ok) return null return (await response.json()) as T } catch { return null } finally { window.clearTimeout(timeout) } } function incidentId(incident?: Incident | null) { return incident?.incident_id ?? incident?.id ?? '' } function incidentServices(incident?: Incident | null) { const services = incident?.affected_services?.filter(Boolean) ?? [] if (services.length > 0) return services return incident?.affected_service ? [incident.affected_service] : [] } function formatLocalTime(value?: string | null) { if (!value) return '--' const date = new Date(value) if (Number.isNaN(date.getTime())) return '--' return date.toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', }) } function timelineStatusClass(status?: string | null) { const normalized = String(status ?? '').toLowerCase() if (normalized.includes('success') || normalized.includes('ok') || normalized.includes('resolved')) { return 'border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]' } if (normalized.includes('fail') || normalized.includes('block') || normalized.includes('error')) { return 'border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]' } if (normalized.includes('warn') || normalized.includes('pending') || normalized.includes('investigating')) { return 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]' } return 'border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]' } function FocusedIncidentTruthPanel({ projectId, incident, incidentId, chain, timeline, loading, error, }: { projectId: string incident?: Incident | null incidentId: string | null chain: AwoooPStatusChain | null timeline: IncidentTimelineResponse | null loading: boolean error: string | null }) { const t = useTranslations('tickets.truth') const stages = timeline?.timeline?.filter((stage) => stage.status !== 'skipped') ?? [] const verifier = timeline?.timeline?.find((stage) => stage.stage === 'verifier') const sourceCorrelation = chain?.source_refs?.correlation const importantEvents = (timeline?.events ?? []) .filter((event) => ( event.source_table === 'automation_operation_log' || event.source_table === 'knowledge_entries' || event.source_table === 'incident_evidence' || event.source_table === 'alert_operation_log' || event.stage === 'executor' || event.stage === 'verifier' || event.stage === 'km' || event.stage === 'ai_router' )) .slice(-5) .reverse() const title = incident?.title || timeline?.title || incidentServices(incident).join(' / ') || t('unknownTitle') const selectedIncidentId = incidentId || timeline?.incident_id || incidentId const encodedProjectId = encodeURIComponent(projectId) const encodedIncidentId = selectedIncidentId ? encodeURIComponent(selectedIncidentId) : '' return (

{t('title')}

{selectedIncidentId || t('emptyIncident')}

{title}

{loading ? ( ) : null} {selectedIncidentId ? ( <> {t('openWorkItems')}
{error ? (
) : null}

{t('metrics.stages')}

{timeline ? stages.length : '--'}

{t('metrics.events')}

{timeline ? timeline.events.length : '--'}

{t('metrics.source')}

{sourceCorrelation ? `${sourceCorrelation.direct_ref_total ?? 0}/${sourceCorrelation.candidate_total ?? 0}/${sourceCorrelation.applied_link_total ?? 0}` : '--'}

{t('metrics.verification')}

{verifier?.status ?? chain?.verification ?? '--'}
{timeline?.ascii_timeline ? (

{timeline.ascii_timeline}

) : (

{loading ? t('loading') : t('timelineEmpty')}

)} {stages.length > 0 ? (
{stages.slice(0, 6).map((stage) => (
{stage.label} {stage.status}

{stage.title}

))}
) : null}
{[ [t('executor'), timeline?.timeline?.find((stage) => stage.stage === 'executor')?.title ?? chain?.execution?.latest_operation_type ?? '--'], [t('ansible'), chain?.execution?.ansible?.latest_playbook_path ?? chain?.execution?.ansible?.latest_catalog_id ?? '--'], [t('mcp'), timeline?.timeline?.find((stage) => stage.stage === 'investigator')?.title ?? '--'], [t('km'), timeline?.timeline?.find((stage) => stage.stage === 'km')?.title ?? '--'], ].map(([label, value]) => (

{label}

{value}

))}
{importantEvents.length > 0 ? (
{importantEvents.map((event, index) => (
{event.status} {event.source_table ?? '--'}

{event.title}

))}
) : null}
) } export function TicketsPanel() { const t = useTranslations('tickets') const searchParams = useSearchParams() const queryIncidentId = searchParams.get('incident_id') const projectId = searchParams.get('project_id') ?? 'awoooi' const [incidents, setIncidents] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [chain, setChain] = useState(null) const [timeline, setTimeline] = useState(null) const [detailLoading, setDetailLoading] = useState(false) const [detailError, setDetailError] = useState(null) useEffect(() => { let cancelled = false setLoading(true) setError(null) fetchJson(`${API_BASE}/api/v1/incidents`, 12_000) .then((data) => { if (cancelled) return if (!data) { setError(t('error')) setIncidents([]) setTotal(0) return } setIncidents(data.incidents ?? []) setTotal(data.count ?? data.total ?? data.incidents?.length ?? 0) }) .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : t('error')) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [t]) const selectedIncidentId = useMemo(() => { if (queryIncidentId) return queryIncidentId return incidentId(incidents[0]) || null }, [incidents, queryIncidentId]) const selectedIncident = useMemo( () => incidents.find((incident) => incidentId(incident) === selectedIncidentId) ?? null, [incidents, selectedIncidentId] ) useEffect(() => { let cancelled = false if (!selectedIncidentId) { setChain(null) setTimeline(null) setDetailError(null) setDetailLoading(false) return () => { cancelled = true } } const targetIncidentId = selectedIncidentId async function loadIncidentTruth() { setDetailLoading(true) setDetailError(null) const encodedProjectId = encodeURIComponent(projectId) const encodedIncidentId = encodeURIComponent(targetIncidentId) const [statusChain, incidentTimeline] = await Promise.all([ fetchJson( `${API_BASE}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}`, 12_000 ), fetchJson( `${API_BASE}/api/v1/incidents/${encodedIncidentId}/timeline`, 12_000 ), ]) if (cancelled) return setChain(statusChain) setTimeline(incidentTimeline) if (!statusChain && !incidentTimeline) { setDetailError(t('truth.loadFailed')) } setDetailLoading(false) } loadIncidentTruth() return () => { cancelled = true } }, [projectId, selectedIncidentId, t]) return (

{t('title')}

{t('subtitle')}

{t('title')} ({loading ? '...' : total})
{loading ? (
{t('loading')}
) : error ? (
{error}
) : incidents.length === 0 ? (
{t('noTickets')}
) : (
{[t('id'), t('title_col'), t('priority'), t('status'), t('signals'), t('createdAt'), t('actions')].map((col) => ( ))} {incidents.map((incident) => { const rowIncidentId = incidentId(incident) const isSelected = rowIncidentId === selectedIncidentId const services = incidentServices(incident) const title = incident.title || services.join(' / ') || t('unknownService') return ( ) })}
{col}
{rowIncidentId.slice(0, 16)}
{title}
{services.length > 1 ? (
{t('serviceCount', { count: services.length })}
) : null}
{incident.severity} {incident.status} {t('signalProposal', { signals: incident.signal_count ?? 0, proposals: incident.proposal_count ?? 0, })} {formatLocalTime(incident.created_at)}
)}
) }