574 lines
22 KiB
TypeScript
574 lines
22 KiB
TypeScript
'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>
|
||
)
|
||
}
|