fix(web): connect tickets to incident truth chain
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user