fix(web): connect alerts to incident evidence chain
This commit is contained in:
@@ -814,7 +814,13 @@
|
||||
"timelineEvents": "事件明細",
|
||||
"timelineSource": "來源",
|
||||
"timelineRoute": "MCP",
|
||||
"timelineWrites": "寫入"
|
||||
"timelineWrites": "寫入",
|
||||
"truthLinksLabel": "真相鏈",
|
||||
"truthLinkMonitoring": "監控",
|
||||
"truthLinkWorkItems": "工作項",
|
||||
"truthLinkRuns": "Runs",
|
||||
"truthLinkApprovals": "審批",
|
||||
"truthLinkTickets": "Tickets"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -1163,6 +1169,9 @@
|
||||
"autoRefresh": "每 {seconds} 秒自動刷新",
|
||||
"incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}",
|
||||
"pageSummary": "顯示第 {from}-{to} 筆 / 共 {total} 筆",
|
||||
"focusedPageSummary": "焦點 Incident:{incidentId};活躍列表符合 {total} 筆",
|
||||
"focusedNotActiveTitle": "{incidentId} 不在目前活躍列表",
|
||||
"focusedNotActiveDescription": "此 Incident 可能已結案、已封存或不屬於目前活躍視窗;上方仍可用 truth-chain、Runs、Work Items、Monitoring 追查完整處理證據。",
|
||||
"statusChainWindow": "AI 流程證據:本頁 {loaded}/{shown} 筆已接上 真相鏈",
|
||||
"previousPage": "上一頁",
|
||||
"nextPage": "下一頁",
|
||||
@@ -1182,7 +1191,38 @@
|
||||
"sourceCoverageFresh": "新鮮",
|
||||
"sourceCoverageStaleHours": "已過期 {hours} 小時",
|
||||
"sourceCoverageStaleDays": "已過期 {days} 天",
|
||||
"sourceCoverageNoEvents": "無事件"
|
||||
"sourceCoverageNoEvents": "無事件",
|
||||
"focus": {
|
||||
"emptyValue": "尚無資料",
|
||||
"title": "焦點告警真相鏈",
|
||||
"subtitle": "{incidentId}|{service}",
|
||||
"loading": "讀取中",
|
||||
"notInActiveList": "此 Incident 不在目前活躍告警列表,但仍可從 AwoooP truth-chain / ADR-100 history 查詢歷史處置證據。",
|
||||
"boundary": "此區塊只做只讀追蹤,不會建立新 Incident、不會觸發修復、不會靜音 Telegram 告警。",
|
||||
"links": {
|
||||
"monitoring": "監控證據",
|
||||
"workItems": "工作項",
|
||||
"runs": "Runs",
|
||||
"approvals": "審批",
|
||||
"tickets": "Tickets"
|
||||
},
|
||||
"metrics": {
|
||||
"sourceRefs": "Source refs",
|
||||
"sourceRefsValue": "{inbound} 入站 / {outbound} 出站",
|
||||
"provider": "Sentry / SigNoz",
|
||||
"mcp": "MCP Gateway",
|
||||
"mcpValue": "{success} / {total}",
|
||||
"ansible": "Ansible"
|
||||
},
|
||||
"sourceStatuses": {
|
||||
"linked": "已匹配 provider event",
|
||||
"candidateFound": "找到候選但未套用",
|
||||
"providerFreshNoMatch": "Provider 有心跳但未匹配此 Incident",
|
||||
"missing": "缺少 provider 證據",
|
||||
"noIncidentContext": "缺少 Incident context",
|
||||
"fetchFailed": "讀取 provider 證據失敗"
|
||||
}
|
||||
}
|
||||
},
|
||||
"navSection": {
|
||||
"aiCore": "AI 核心",
|
||||
|
||||
@@ -814,7 +814,13 @@
|
||||
"timelineEvents": "事件明細",
|
||||
"timelineSource": "來源",
|
||||
"timelineRoute": "MCP",
|
||||
"timelineWrites": "寫入"
|
||||
"timelineWrites": "寫入",
|
||||
"truthLinksLabel": "真相鏈",
|
||||
"truthLinkMonitoring": "監控",
|
||||
"truthLinkWorkItems": "工作項",
|
||||
"truthLinkRuns": "Runs",
|
||||
"truthLinkApprovals": "審批",
|
||||
"truthLinkTickets": "Tickets"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -1163,6 +1169,9 @@
|
||||
"autoRefresh": "每 {seconds} 秒自動刷新",
|
||||
"incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}",
|
||||
"pageSummary": "顯示第 {from}-{to} 筆 / 共 {total} 筆",
|
||||
"focusedPageSummary": "焦點 Incident:{incidentId};活躍列表符合 {total} 筆",
|
||||
"focusedNotActiveTitle": "{incidentId} 不在目前活躍列表",
|
||||
"focusedNotActiveDescription": "此 Incident 可能已結案、已封存或不屬於目前活躍視窗;上方仍可用 truth-chain、Runs、Work Items、Monitoring 追查完整處理證據。",
|
||||
"statusChainWindow": "AI 流程證據:本頁 {loaded}/{shown} 筆已接上 真相鏈",
|
||||
"previousPage": "上一頁",
|
||||
"nextPage": "下一頁",
|
||||
@@ -1182,7 +1191,38 @@
|
||||
"sourceCoverageFresh": "新鮮",
|
||||
"sourceCoverageStaleHours": "已過期 {hours} 小時",
|
||||
"sourceCoverageStaleDays": "已過期 {days} 天",
|
||||
"sourceCoverageNoEvents": "無事件"
|
||||
"sourceCoverageNoEvents": "無事件",
|
||||
"focus": {
|
||||
"emptyValue": "尚無資料",
|
||||
"title": "焦點告警真相鏈",
|
||||
"subtitle": "{incidentId}|{service}",
|
||||
"loading": "讀取中",
|
||||
"notInActiveList": "此 Incident 不在目前活躍告警列表,但仍可從 AwoooP truth-chain / ADR-100 history 查詢歷史處置證據。",
|
||||
"boundary": "此區塊只做只讀追蹤,不會建立新 Incident、不會觸發修復、不會靜音 Telegram 告警。",
|
||||
"links": {
|
||||
"monitoring": "監控證據",
|
||||
"workItems": "工作項",
|
||||
"runs": "Runs",
|
||||
"approvals": "審批",
|
||||
"tickets": "Tickets"
|
||||
},
|
||||
"metrics": {
|
||||
"sourceRefs": "Source refs",
|
||||
"sourceRefsValue": "{inbound} 入站 / {outbound} 出站",
|
||||
"provider": "Sentry / SigNoz",
|
||||
"mcp": "MCP Gateway",
|
||||
"mcpValue": "{success} / {total}",
|
||||
"ansible": "Ansible"
|
||||
},
|
||||
"sourceStatuses": {
|
||||
"linked": "已匹配 provider event",
|
||||
"candidateFound": "找到候選但未套用",
|
||||
"providerFreshNoMatch": "Provider 有心跳但未匹配此 Incident",
|
||||
"missing": "缺少 provider 證據",
|
||||
"noIncidentContext": "缺少 Incident context",
|
||||
"fetchFailed": "讀取 provider 證據失敗"
|
||||
}
|
||||
}
|
||||
},
|
||||
"navSection": {
|
||||
"aiCore": "AI 核心",
|
||||
|
||||
@@ -11,14 +11,37 @@
|
||||
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useIncidents } from '@/hooks/useIncidents'
|
||||
import { useIncidentStatusChains } from '@/hooks/useIncidentStatusChains'
|
||||
import { IncidentCard } from '@/components/incident'
|
||||
import {
|
||||
AwoooPStatusChainPanel,
|
||||
type AwoooPStatusChain,
|
||||
} from '@/components/awooop/status-chain'
|
||||
import { IwoooSReadOnlyBridge } from '@/components/security/iwooos-read-only-bridge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { API_V1_URL } from '@/lib/config'
|
||||
import { Bell, BellOff, RefreshCw, AlertTriangle, AlertCircle, Info, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type { IncidentResponse } from '@/lib/api-client'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
BellOff,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
Link2,
|
||||
ListChecks,
|
||||
Monitor,
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Severity helpers
|
||||
@@ -138,6 +161,155 @@ function freshnessToneClass(ageHours: number | null) {
|
||||
return 'border-status-healthy/30 bg-status-healthy/5 text-status-healthy'
|
||||
}
|
||||
|
||||
function valueOrEmpty(value: unknown, emptyLabel: string) {
|
||||
if (value === null || value === undefined || value === '') return emptyLabel
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function FocusIncidentEvidencePanel({
|
||||
incidentId,
|
||||
projectId,
|
||||
incident,
|
||||
chain,
|
||||
loading,
|
||||
}: {
|
||||
incidentId: string
|
||||
projectId: string
|
||||
incident?: IncidentResponse | null
|
||||
chain?: AwoooPStatusChain | null
|
||||
loading: boolean
|
||||
}) {
|
||||
const t = useTranslations('alerts.focus')
|
||||
const emptyLabel = t('emptyValue')
|
||||
const encodedProjectId = encodeURIComponent(projectId)
|
||||
const encodedIncidentId = encodeURIComponent(incidentId)
|
||||
const sourceCorrelation = chain?.source_refs?.correlation
|
||||
const sourceStatus = String(sourceCorrelation?.status ?? 'missing')
|
||||
const statusLabels: Record<string, string> = {
|
||||
linked: t('sourceStatuses.linked'),
|
||||
candidate_found: t('sourceStatuses.candidateFound'),
|
||||
provider_fresh_no_match: t('sourceStatuses.providerFreshNoMatch'),
|
||||
missing: t('sourceStatuses.missing'),
|
||||
no_incident_context: t('sourceStatuses.noIncidentContext'),
|
||||
fetch_failed: t('sourceStatuses.fetchFailed'),
|
||||
}
|
||||
const sourceStatusLabel = statusLabels[sourceStatus] ?? valueOrEmpty(sourceStatus, emptyLabel)
|
||||
const mcpGateway = chain?.mcp?.gateway
|
||||
const ansible = chain?.execution?.ansible
|
||||
const navItems = [
|
||||
{
|
||||
key: 'monitoring',
|
||||
label: t('links.monitoring'),
|
||||
href: `/monitoring?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: Monitor,
|
||||
},
|
||||
{
|
||||
key: 'workItems',
|
||||
label: t('links.workItems'),
|
||||
href: `/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: ListChecks,
|
||||
},
|
||||
{
|
||||
key: 'runs',
|
||||
label: t('links.runs'),
|
||||
href: `/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: Activity,
|
||||
},
|
||||
{
|
||||
key: 'approvals',
|
||||
label: t('links.approvals'),
|
||||
href: `/awooop/approvals?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
key: 'tickets',
|
||||
label: t('links.tickets'),
|
||||
href: `/tickets?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: GitBranch,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="alerts-incident-focus"
|
||||
className="mb-6 overflow-hidden rounded-lg border border-[#e0ddd4] bg-white"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-4">
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t('title')}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[#77736a]">
|
||||
{t('subtitle', {
|
||||
incidentId,
|
||||
service: incident?.affected_services?.[0] ?? valueOrEmpty(chain?.source_id, emptyLabel),
|
||||
})}
|
||||
{loading ? ` · ${t('loading')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{navItems.map(({ key, label, href, Icon }) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1.5 text-xs font-semibold text-[#3f3a32] hover:bg-[#f5f1e8]"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
<Link2 className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.sourceRefs')}</p>
|
||||
<p className="mt-2 font-mono text-sm font-semibold text-[#141413]">
|
||||
{t('metrics.sourceRefsValue', {
|
||||
inbound: chain?.source_refs?.inbound_total ?? 0,
|
||||
outbound: chain?.source_refs?.outbound_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.provider')}</p>
|
||||
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]" title={sourceStatusLabel}>
|
||||
{sourceStatusLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.mcp')}</p>
|
||||
<p className="mt-2 font-mono text-sm font-semibold text-[#141413]">
|
||||
{t('metrics.mcpValue', { success: mcpGateway?.success ?? 0, total: mcpGateway?.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.ansible')}</p>
|
||||
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]" title={valueOrEmpty(ansible?.latest_playbook_path, emptyLabel)}>
|
||||
{valueOrEmpty(ansible?.latest_status ?? chain?.execution?.latest_status, emptyLabel)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!incident && (
|
||||
<div className="border-t border-[#e0ddd4] bg-[#fff7e8] px-4 py-3 text-xs leading-5 text-[#8a5a08]">
|
||||
{t('notInActiveList')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs leading-5 text-[#5f5b52]">
|
||||
{t('boundary')}
|
||||
</div>
|
||||
|
||||
<AwoooPStatusChainPanel chain={chain ?? null} compact className="border-x-0 border-b-0" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page
|
||||
// =============================================================================
|
||||
@@ -145,6 +317,9 @@ function freshnessToneClass(ageHours: number | null) {
|
||||
export default function AlertsPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations()
|
||||
const tAlerts = useTranslations('alerts')
|
||||
const searchParams = useSearchParams()
|
||||
const projectId = searchParams.get('project_id') ?? 'awoooi'
|
||||
const focusedIncidentId = searchParams.get('incident_id')?.trim() || null
|
||||
|
||||
const { incidents, isLoading, error, refresh, lastUpdated } = useIncidents({
|
||||
pollInterval: 15000,
|
||||
@@ -163,24 +338,42 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
||||
(a, b) => (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9)
|
||||
)
|
||||
}, [incidents])
|
||||
const visibleSorted = useMemo(() => {
|
||||
if (!focusedIncidentId) return sorted
|
||||
return sorted.filter(incident => incident.incident_id === focusedIncidentId)
|
||||
}, [focusedIncidentId, sorted])
|
||||
const focusedIncident = focusedIncidentId
|
||||
? sorted.find(incident => incident.incident_id === focusedIncidentId) ?? null
|
||||
: null
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / ALERTS_PAGE_SIZE))
|
||||
const totalPages = Math.max(1, Math.ceil(visibleSorted.length / ALERTS_PAGE_SIZE))
|
||||
const pageStart = (currentPage - 1) * ALERTS_PAGE_SIZE
|
||||
const pageIncidents = sorted.slice(pageStart, pageStart + ALERTS_PAGE_SIZE)
|
||||
const pageFrom = sorted.length === 0 ? 0 : pageStart + 1
|
||||
const pageIncidents = visibleSorted.slice(pageStart, pageStart + ALERTS_PAGE_SIZE)
|
||||
const pageFrom = visibleSorted.length === 0 ? 0 : pageStart + 1
|
||||
const pageTo = pageStart + pageIncidents.length
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(page => Math.min(Math.max(page, 1), totalPages))
|
||||
}, [totalPages])
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [focusedIncidentId])
|
||||
|
||||
const statusChainIncidentIds = useMemo(() => {
|
||||
return Array.from(new Set([
|
||||
...pageIncidents.map(incident => incident.incident_id),
|
||||
...(focusedIncidentId ? [focusedIncidentId] : []),
|
||||
]))
|
||||
}, [focusedIncidentId, pageIncidents])
|
||||
const {
|
||||
statusChains,
|
||||
requestedIncidentIds,
|
||||
isLoading: isStatusChainLoading,
|
||||
} = useIncidentStatusChains({
|
||||
incidentIds: pageIncidents.map(incident => incident.incident_id),
|
||||
limit: ALERTS_PAGE_SIZE,
|
||||
incidentIds: statusChainIncidentIds,
|
||||
limit: ALERTS_PAGE_SIZE + 1,
|
||||
projectId,
|
||||
refreshKey: lastUpdated?.toISOString() ?? null,
|
||||
})
|
||||
const truthChainLoaded = requestedIncidentIds.filter(incidentId => {
|
||||
@@ -395,11 +588,23 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sorted.length > 0 && (
|
||||
{focusedIncidentId && (
|
||||
<FocusIncidentEvidencePanel
|
||||
incidentId={focusedIncidentId}
|
||||
projectId={projectId}
|
||||
incident={focusedIncident}
|
||||
chain={statusChains[focusedIncidentId] ?? null}
|
||||
loading={isStatusChainLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleSorted.length > 0 && (
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-nothing-gray-200 bg-white px-4 py-3">
|
||||
<div>
|
||||
<p className="font-body text-sm font-semibold text-nothing-black">
|
||||
{tAlerts('pageSummary', { from: pageFrom, to: pageTo, total: sorted.length })}
|
||||
{focusedIncidentId
|
||||
? tAlerts('focusedPageSummary', { incidentId: focusedIncidentId, total: visibleSorted.length })
|
||||
: tAlerts('pageSummary', { from: pageFrom, to: pageTo, total: visibleSorted.length })}
|
||||
</p>
|
||||
<p className="mt-1 font-body text-xs text-nothing-gray-500">
|
||||
{tAlerts('statusChainWindow', {
|
||||
@@ -435,6 +640,17 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusedIncidentId && visibleSorted.length === 0 && !isLoading && (
|
||||
<div className="mb-6 rounded-lg border border-status-warning/25 bg-status-warning/5 px-4 py-3">
|
||||
<p className="font-body text-sm font-semibold text-status-warning">
|
||||
{tAlerts('focusedNotActiveTitle', { incidentId: focusedIncidentId })}
|
||||
</p>
|
||||
<p className="mt-1 font-body text-xs leading-5 text-nothing-gray-600">
|
||||
{tAlerts('focusedNotActiveDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 rounded-lg bg-status-critical/10 border border-status-critical/20 flex items-center gap-2">
|
||||
@@ -481,6 +697,7 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
||||
<IncidentCard
|
||||
key={incident.incident_id}
|
||||
incident={incident}
|
||||
projectId={projectId}
|
||||
statusChain={statusChains[incident.incident_id] ?? null}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -19,8 +19,10 @@ import type { IncidentResponse, DecisionInfo, IncidentTimelineResponse } from '@
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { CURRENT_USER } from '@/lib/constants'
|
||||
import { useCSRF } from '@/hooks/useCSRF'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { FlowPipeline, type FlowStage } from './flow-pipeline'
|
||||
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
|
||||
import { Activity, GitBranch, Lightbulb, ListChecks, Monitor, ShieldCheck } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -34,6 +36,7 @@ export interface IncidentCardProps {
|
||||
incident: IncidentResponse
|
||||
decision?: DecisionInfo | null
|
||||
statusChain?: AwoooPStatusChain | null
|
||||
projectId?: string
|
||||
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
|
||||
}
|
||||
|
||||
@@ -286,7 +289,7 @@ function useApprovalAction(
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function IncidentCard({ incident, decision, statusChain, onApprovalChange }: IncidentCardProps) {
|
||||
export function IncidentCard({ incident, decision, statusChain, projectId = 'awoooi', onApprovalChange }: IncidentCardProps) {
|
||||
const t = useTranslations('incident.card')
|
||||
const { csrfToken } = useCSRF()
|
||||
|
||||
@@ -428,6 +431,40 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange
|
||||
latest: latestSource,
|
||||
})
|
||||
: null
|
||||
const encodedProjectId = encodeURIComponent(projectId)
|
||||
const encodedIncidentId = encodeURIComponent(incident.incident_id)
|
||||
const truthLinks = [
|
||||
{
|
||||
key: 'monitoring',
|
||||
label: t('truthLinkMonitoring'),
|
||||
href: `/monitoring?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: Monitor,
|
||||
},
|
||||
{
|
||||
key: 'workItems',
|
||||
label: t('truthLinkWorkItems'),
|
||||
href: `/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: ListChecks,
|
||||
},
|
||||
{
|
||||
key: 'runs',
|
||||
label: t('truthLinkRuns'),
|
||||
href: `/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: Activity,
|
||||
},
|
||||
{
|
||||
key: 'approvals',
|
||||
label: t('truthLinkApprovals'),
|
||||
href: `/awooop/approvals?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
key: 'tickets',
|
||||
label: t('truthLinkTickets'),
|
||||
href: `/tickets?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: GitBranch,
|
||||
},
|
||||
]
|
||||
|
||||
const serviceName = incident.affected_services?.[0] ?? '--'
|
||||
const duration = formatDuration(incident.created_at)
|
||||
@@ -738,6 +775,44 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
margin: '0 14px 10px',
|
||||
padding: '7px 9px',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 6,
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{ fontSize: 11, color: '#87867f', fontWeight: 700 }}>
|
||||
{t('truthLinksLabel')}
|
||||
</span>
|
||||
{truthLinks.map(({ key, label, href, Icon }) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '3px 7px',
|
||||
border: '0.5px solid #d8d3c7',
|
||||
borderRadius: 5,
|
||||
background: '#faf9f3',
|
||||
color: '#3f3a32',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Icon aria-hidden="true" size={12} strokeWidth={1.8} />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTimelineToggle}
|
||||
style={{
|
||||
@@ -954,8 +1029,9 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange
|
||||
}}>
|
||||
<div style={{ color: '#141413', marginBottom: 4 }}>{decisionAction}</div>
|
||||
{decisionReasoning && (
|
||||
<div style={{ color: '#87867f', fontStyle: 'italic' }}>
|
||||
💡 {decisionReasoning.slice(0, 150)}{decisionReasoning.length > 150 ? '...' : ''}
|
||||
<div style={{ color: '#87867f', fontStyle: 'italic', display: 'flex', alignItems: 'flex-start', gap: 5 }}>
|
||||
<Lightbulb aria-hidden="true" size={14} strokeWidth={1.8} style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<span>{decisionReasoning.slice(0, 150)}{decisionReasoning.length > 150 ? '...' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user