Files
awoooi/apps/web/src/components/panels/TicketsPanel.tsx
Your Name e9977f39c1
All checks were successful
CD Pipeline / tests (push) Successful in 1m31s
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / build-and-deploy (push) Successful in 3m36s
CD Pipeline / post-deploy-checks (push) Successful in 1m27s
fix(web): connect tickets to incident truth chain
2026-05-31 19:58:47 +08:00

574 lines
22 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'
/**
* 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<string, unknown>
}
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<string, string> = {
P0: '#cc2200',
P1: '#F59E0B',
P2: '#4A90D9',
P3: '#22C55E',
}
const STATUS_COLOR: Record<string, string> = {
open: '#cc2200',
investigating: '#F59E0B',
in_progress: '#F59E0B',
mitigating: '#4A90D9',
resolved: '#22C55E',
closed: '#87867f',
}
async function fetchJson<T>(url: string, timeoutMs = 10_000): Promise<T | null> {
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 (
<section className="border border-[#e0ddd4] bg-white" aria-busy={loading}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex min-w-0 items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]">
<SearchCheck className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-[#141413]">{t('title')}</h2>
<p className="mt-1 truncate font-mono text-xs text-[#77736a]">
{selectedIncidentId || t('emptyIncident')}
</p>
<p className="mt-1 truncate text-xs text-[#5f5b52]" title={title}>
{title}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{loading ? (
<span className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#5f5b52]">
<RefreshCw className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
{t('loading')}
</span>
) : null}
{selectedIncidentId ? (
<>
<Link
href={`/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#d97757]"
>
{t('openWorkItems')}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<Link
href={`/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#1f6feb]"
>
{t('openRuns')}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</>
) : null}
</div>
</div>
{error ? (
<div className="flex items-start gap-2 border-b border-[#ead9b4] bg-[#fff7e8] px-4 py-3 text-xs leading-5 text-[#8a5a08]">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
{error}
</div>
) : null}
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.stages')}</p>
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">
{timeline ? stages.length : '--'}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.events')}</p>
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">
{timeline ? timeline.events.length : '--'}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.source')}</p>
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]">
{sourceCorrelation
? `${sourceCorrelation.direct_ref_total ?? 0}/${sourceCorrelation.candidate_total ?? 0}/${sourceCorrelation.applied_link_total ?? 0}`
: '--'}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.verification')}</p>
<span className={cn('mt-2 inline-flex border px-2 py-0.5 text-xs font-semibold', timelineStatusClass(verifier?.status ?? chain?.verification))}>
{verifier?.status ?? chain?.verification ?? '--'}
</span>
</div>
</div>
<AwoooPStatusChainPanel chain={chain} className="border-x-0 border-t-0" />
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1.15fr_0.85fr]">
<div className="min-w-0 bg-white p-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h3 className="text-sm font-semibold text-[#141413]">{t('flowTitle')}</h3>
</div>
{timeline?.ascii_timeline ? (
<p className="mt-3 break-words border border-[#eee9dd] bg-[#faf9f3] px-3 py-2 font-mono text-xs leading-6 text-[#5f5b52]">
{timeline.ascii_timeline}
</p>
) : (
<p className="mt-3 text-sm text-[#77736a]">
{loading ? t('loading') : t('timelineEmpty')}
</p>
)}
{stages.length > 0 ? (
<div className="mt-3 grid gap-2 md:grid-cols-2">
{stages.slice(0, 6).map((stage) => (
<div key={stage.stage} className="min-w-0 border border-[#eee9dd] bg-white px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-semibold text-[#77736a]">{stage.label}</span>
<span className={cn('shrink-0 border px-2 py-0.5 text-[11px] font-semibold', timelineStatusClass(stage.status))}>
{stage.status}
</span>
</div>
<p className="mt-1 truncate text-xs font-semibold text-[#141413]" title={stage.title}>
{stage.title}
</p>
</div>
))}
</div>
) : null}
</div>
<div className="min-w-0 bg-white p-4">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h3 className="text-sm font-semibold text-[#141413]">{t('evidenceTitle')}</h3>
</div>
<div className="mt-3 grid gap-px border border-[#e0ddd4] bg-[#e0ddd4]">
{[
[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]) => (
<div key={label} className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={value}>
{value}
</p>
</div>
))}
</div>
{importantEvents.length > 0 ? (
<div className="mt-3 divide-y divide-[#eee9dd] border border-[#eee9dd]">
{importantEvents.map((event, index) => (
<div key={`${event.stage}-${event.timestamp}-${index}`} className="px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className={cn('border px-2 py-0.5 text-[11px] font-semibold', timelineStatusClass(event.status))}>
{event.status}
</span>
<span className="font-mono text-[11px] text-[#77736a]">{event.source_table ?? '--'}</span>
</div>
<p className="mt-1 truncate text-xs font-semibold text-[#141413]" title={event.title}>
{event.title}
</p>
</div>
))}
</div>
) : null}
</div>
</div>
</section>
)
}
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<Incident[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [chain, setChain] = useState<AwoooPStatusChain | null>(null)
const [timeline, setTimeline] = useState<IncidentTimelineResponse | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetchJson<IncidentListResponse>(`${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<AwoooPStatusChain>(
`${API_BASE}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}`,
12_000
),
fetchJson<IncidentTimelineResponse>(
`${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 (
<div className="min-h-full space-y-5 bg-[#f5f4ed] p-6">
<div>
<h1 className="m-0 text-lg font-bold text-[#141413]">{t('title')}</h1>
<p className="mt-1 text-xs text-[#87867f]">{t('subtitle')}</p>
</div>
<FocusedIncidentTruthPanel
projectId={projectId}
incident={selectedIncident}
incidentId={selectedIncidentId}
chain={chain}
timeline={timeline}
loading={detailLoading}
error={detailError}
/>
<section className="overflow-hidden border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex items-center gap-2 text-sm font-bold text-[#141413]">
<span className="h-1.5 w-1.5 rounded-full bg-[#d97757]" />
{t('title')} ({loading ? '...' : total})
</div>
<span className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#5f5b52]">
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
{t('readOnly')}
</span>
</div>
{loading ? (
<div className="px-8 py-10 text-center text-sm text-[#87867f]">{t('loading')}</div>
) : error ? (
<div className="px-8 py-10 text-center text-sm text-[#cc2200]">{error}</div>
) : incidents.length === 0 ? (
<div className="px-8 py-10 text-center text-sm text-[#87867f]">{t('noTickets')}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-[#faf9f3]">
{[t('id'), t('title_col'), t('priority'), t('status'), t('signals'), t('createdAt'), t('actions')].map((col) => (
<th key={col} className="border-b border-[#e0ddd4] px-4 py-3 text-left text-xs font-semibold text-[#87867f]">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{incidents.map((incident) => {
const rowIncidentId = incidentId(incident)
const isSelected = rowIncidentId === selectedIncidentId
const services = incidentServices(incident)
const title = incident.title || services.join(' / ') || t('unknownService')
return (
<tr
key={rowIncidentId || `${incident.created_at}-${title}`}
className={cn(
'border-b border-[#f0ede4]',
isSelected && 'bg-[#fff7e8]'
)}
>
<td className="px-4 py-3 align-top font-mono text-xs text-[#5f5b52]">
<Link
href={`/tickets?project_id=${encodeURIComponent(projectId)}&incident_id=${encodeURIComponent(rowIncidentId)}` as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 font-semibold text-[#141413] hover:border-[#d97757]"
>
{rowIncidentId.slice(0, 16)}
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</Link>
</td>
<td className="max-w-[360px] px-4 py-3 align-top font-medium text-[#141413]">
<div className="truncate" title={title}>{title}</div>
{services.length > 1 ? (
<div className="mt-1 truncate text-xs text-[#87867f]">
{t('serviceCount', { count: services.length })}
</div>
) : null}
</td>
<td className="px-4 py-3 align-top">
<span
style={{
color: SEV_COLOR[incident.severity] ?? '#87867f',
background: `${SEV_COLOR[incident.severity] ?? '#87867f'}18`,
borderColor: `${SEV_COLOR[incident.severity] ?? '#87867f'}40`,
}}
className="inline-flex border px-2 py-0.5 text-xs font-bold"
>
{incident.severity}
</span>
</td>
<td className="px-4 py-3 align-top">
<span
style={{ color: STATUS_COLOR[incident.status] ?? '#87867f' }}
className="text-xs font-semibold"
>
{incident.status}
</span>
</td>
<td className="px-4 py-3 align-top font-mono text-xs text-[#5f5b52]">
{t('signalProposal', {
signals: incident.signal_count ?? 0,
proposals: incident.proposal_count ?? 0,
})}
</td>
<td className="px-4 py-3 align-top text-xs text-[#87867f]">
{formatLocalTime(incident.created_at)}
</td>
<td className="px-4 py-3 align-top">
<Link
href={`/awooop/work-items?project_id=${encodeURIComponent(projectId)}&incident_id=${encodeURIComponent(rowIncidentId)}` as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{t('openTruth')}
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
</div>
)
}