fix(web): connect tickets to incident truth chain
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

This commit is contained in:
Your Name
2026-05-31 19:57:21 +08:00
parent 33601f7b1c
commit e9977f39c1
4 changed files with 640 additions and 61 deletions

View File

@@ -1497,8 +1497,37 @@
"status": "狀態",
"priority": "優先級",
"createdAt": "建立時間",
"signals": "訊號 / 提案",
"actions": "操作",
"error": "載入失敗",
"noTickets": "目前無工單"
"noTickets": "目前無工單",
"readOnly": "純讀,不觸發 AI 推理",
"unknownService": "未標示服務",
"serviceCount": "{count} 個受影響服務",
"signalProposal": "signal={signals} / proposal={proposals}",
"openTruth": "真相鏈",
"truth": {
"title": "焦點 Incident 真相鏈",
"unknownTitle": "未標示事件標題",
"emptyIncident": "尚未選取 Incident",
"loading": "讀取真相鏈",
"loadFailed": "此 Incident 尚未取得可判讀的狀態鏈或處理時間線",
"openWorkItems": "Work Items",
"openRuns": "Runs",
"metrics": {
"stages": "階段",
"events": "事件",
"source": "Sentry / SigNoz",
"verification": "驗證"
},
"flowTitle": "處理流程",
"timelineEmpty": "尚無處理時間線",
"evidenceTitle": "執行與學習證據",
"executor": "Executor",
"ansible": "Ansible / PlayBook",
"mcp": "MCP 調查",
"km": "KM / Learning"
}
},
"users": {
"title": "操作稽核",

View File

@@ -1497,8 +1497,37 @@
"status": "狀態",
"priority": "優先級",
"createdAt": "建立時間",
"signals": "訊號 / 提案",
"actions": "操作",
"error": "載入失敗",
"noTickets": "目前無工單"
"noTickets": "目前無工單",
"readOnly": "純讀,不觸發 AI 推理",
"unknownService": "未標示服務",
"serviceCount": "{count} 個受影響服務",
"signalProposal": "signal={signals} / proposal={proposals}",
"openTruth": "真相鏈",
"truth": {
"title": "焦點 Incident 真相鏈",
"unknownTitle": "未標示事件標題",
"emptyIncident": "尚未選取 Incident",
"loading": "讀取真相鏈",
"loadFailed": "此 Incident 尚未取得可判讀的狀態鏈或處理時間線",
"openWorkItems": "Work Items",
"openRuns": "Runs",
"metrics": {
"stages": "階段",
"events": "事件",
"source": "Sentry / SigNoz",
"verification": "驗證"
},
"flowTitle": "處理流程",
"timelineEmpty": "尚無處理時間線",
"evidenceTitle": "執行與學習證據",
"executor": "Executor",
"ansible": "Ansible / PlayBook",
"mcp": "MCP 調查",
"km": "KM / Learning"
}
},
"users": {
"title": "操作稽核",

View File

@@ -6,26 +6,80 @@
* Sprint 5: 從 /tickets/page.tsx 抽取
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 2026-05-31 Codex: 接上 Incident status-chain / timeline讓 Tickets 不再只是清單。
*/
import { useState, useEffect } from 'react'
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 {
id: string
title: string
incident_id?: string
id?: string
title?: string | null
severity: string
status: string
signal_count?: number
proposal_count?: number
created_at: string
affected_service: string | null
updated_at?: string
affected_services?: string[]
affected_service?: string | null
}
interface IncidentListResponse {
incidents: Incident[]
total: number
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> = {
@@ -37,84 +91,483 @@ const SEV_COLOR: Record<string, string> = {
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(() => {
fetch(`${API_BASE}/api/v1/incidents`)
.then(r => r.json())
.then((data: IncidentListResponse) => {
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.total ?? 0)
setLoading(false)
setTotal(data.count ?? data.total ?? data.incidents?.length ?? 0)
})
.catch(err => { setError(String(err)); setLoading(false) })
}, [])
.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 style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
<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>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')} ({loading ? '...' : total})
<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 style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
<div className="px-8 py-10 text-center text-sm text-[#87867f]">{t('loading')}</div>
) : error ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
<div className="px-8 py-10 text-center text-sm text-[#cc2200]">{error}</div>
) : incidents.length === 0 ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noTickets')}</div>
<div className="px-8 py-10 text-center text-sm text-[#87867f]">{t('noTickets')}</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('id'), t('title_col'), t('priority'), t('status'), t('createdAt')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{incidents.map((inc) => (
<tr key={inc.id} style={{ borderBottom: '0.5px solid #f0ede4' }}>
<td style={{ padding: '8px 14px', color: '#87867f', fontSize: 11, fontFamily: 'monospace' }}>{inc.id.slice(0, 8)}</td>
<td style={{ padding: '8px 14px', fontWeight: 500, color: '#141413', maxWidth: 300 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{inc.title}</div>
{inc.affected_service && (
<div style={{ fontSize: 11, color: '#87867f', marginTop: 2 }}>{inc.affected_service}</div>
)}
</td>
<td style={{ padding: '8px 14px' }}>
<span style={{ fontSize: 11, fontWeight: 700, color: SEV_COLOR[inc.severity] ?? '#87867f', background: `${SEV_COLOR[inc.severity] ?? '#87867f'}18`, border: `0.5px solid ${SEV_COLOR[inc.severity] ?? '#87867f'}40`, borderRadius: 4, padding: '1px 6px' }}>
{inc.severity}
</span>
</td>
<td style={{ padding: '8px 14px' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: STATUS_COLOR[inc.status] ?? '#87867f' }}>
{inc.status}
</span>
</td>
<td style={{ padding: '8px 14px', color: '#87867f', fontSize: 11 }}>
{new Date(inc.created_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
<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>
))}
</tbody>
</table>
</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>
)}
</div>
</section>
</div>
)
}

View File

@@ -1,3 +1,71 @@
## 2026-05-31Tickets 接上 Incident 真相鏈與處理時間線
**背景**
- 使用者指出 Telegram 告警、詳情、歷史與前端頁面無法讓人判斷同一個 Incident 是否重複、AI 流程跑到哪、是否已自動修復、是否卡在人工介入。
- Work Items 已能看到 `INC-20260530-0DD83C` 的 status-chain / timeline`/tickets` 仍只有事件清單,且前端欄位還停在舊 schema`total/id/title/affected_service`,後端實際回 `count/incident_id/affected_services`
**本次調整**
- `apps/web/src/components/panels/TicketsPanel.tsx`
- 修正 incident list schema 對齊,清單總數改讀 `count`,列資料改讀 `incident_id``affected_services``signal_count``proposal_count`
- 支援 `?project_id=awoooi&incident_id=...` 焦點事件視圖。
- 焦點事件讀取:
- `/api/v1/platform/status-chain?project_id=...&incident_id=...`
- `/api/v1/incidents/{incident_id}/timeline`
- 新增「焦點 Incident 真相鏈」區塊階段數、事件數、Sentry / SigNoz 關聯、驗證狀態、AwoooP 狀態鏈、處理流程、Executor / Ansible / MCP / KM 證據。
- 每列事件可直接打開同一頁焦點視圖與 Work Items 真相鏈。
- `apps/web/messages/zh-TW.json` / `apps/web/messages/en.json`
- 補齊 Tickets 真相鏈、純讀提示、訊號 / 提案、Work Items / Runs 導航文案。
**驗證**
```text
python3 -m json.tool apps/web/messages/zh-TW.json / en.json -> pass
git diff --check -> pass
pnpm --dir apps/web exec tsc --noEmit --tsBuildInfoFile /tmp/awoooi-tickets-truth-chain-20260531.tsbuildinfo -> pass
NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build -> pass
local browser with read-only production API proxy:
/zh-TW/tickets?project_id=awoooi&incident_id=INC-20260530-0DD83C
hasTickets=true
hasTruth=true
hasStatusChain=true
hasTimeline=true
hasEvidence=true
hasIncident=true
hasWorkItemsLink=true
hasRunsLink=true
hasReadOnly=true
hasLoadFailed=false
hasListRows=true
listCount=504
canScroll=true
horizontalOverflow=false
screenshot=/tmp/awoooi-tickets-truth-chain-local.png
焦點 Incident 實際顯示:
title=DockerContainerMemoryLimitPressure
stages=10
events=18
status_chain_stage=execution_succeeded / success
repair_state=executed
verification=degraded
next_step=manual_verify_or_repair
mcp_gateway=17/19 success, failed=2, blocked=0
top_mcp=ssh_get_container_status
ansible=infra/ansible/playbooks/188-ai-web.yml
ansible_rc=2
km=1
```
**目前整體進度**
- Telegram / AwoooP / 前端真相鏈可見性:約 82%Runs、Work Items、Tickets 已接上同一組 Incident status-chain / timeline 證據。
- 前端 AI 自動化管理介面同步:約 91%Tickets 從空清單型頁面升級為可追問單一 Incident 的操作頁。
- 整體 AI 自動化飛輪:約 75%;可觀測性和人工接手透明度推進,但仍不可宣稱 24h 完整全自動修復閉環。
- 24h 完整 AI Agent 自動修復 production claim0%;仍需 production 統計連續證明。
## 2026-05-31IwoooS 首屏資安工作雷達
**背景**