fix(web): connect monitoring to incident evidence chain
This commit is contained in:
@@ -1327,10 +1327,74 @@
|
||||
"hostStatus": "主機狀態 (FOUR-HOST ARCHITECTURE)",
|
||||
"serviceList": "服務清單",
|
||||
"serviceName": "服務名稱",
|
||||
"serviceHealth": "服務健康",
|
||||
"service": "服務",
|
||||
"status": "狀態",
|
||||
"latency": "延遲",
|
||||
"uptime": "可用率",
|
||||
"lastCheck": "最後檢查"
|
||||
"lastCheck": "最後檢查",
|
||||
"incidentFocus": {
|
||||
"emptyValue": "尚無資料",
|
||||
"title": "焦點 Incident 監控證據鏈",
|
||||
"subtitle": "{incidentId}|{incidentTitle}",
|
||||
"loading": "正在讀取 Incident status-chain 與 timeline...",
|
||||
"loadFailed": "焦點 Incident 資料讀取失敗:{error}",
|
||||
"boundary": "此區塊只讀取監控證據,不會自動標記 Sentry/SigNoz 已匹配,也不會觸發修復或靜音告警。最新 PlayBook:{playbook};executor:{executor}。",
|
||||
"human": {
|
||||
"yes": "需要人工",
|
||||
"no": "未要求人工"
|
||||
},
|
||||
"links": {
|
||||
"workItems": "工作項",
|
||||
"runs": "Runs",
|
||||
"approvals": "審批",
|
||||
"authorizations": "授權",
|
||||
"tickets": "Tickets"
|
||||
},
|
||||
"sourceStatuses": {
|
||||
"linked": "已匹配 provider event",
|
||||
"candidateFound": "找到候選但未套用",
|
||||
"providerFreshNoMatch": "Provider 有心跳但未匹配此 Incident",
|
||||
"missing": "缺少 provider 證據",
|
||||
"noIncidentContext": "缺少 Incident context",
|
||||
"fetchFailed": "讀取 provider 證據失敗"
|
||||
},
|
||||
"sourceReasons": {
|
||||
"providerHeartbeatNoMatch": "Sentry / SigNoz 有心跳,但此 Incident 尚未匹配 provider event",
|
||||
"noMatchingProviderSourceEvent": "沒有找到可對應此 Incident 的 provider source event",
|
||||
"noIncidentIds": "缺少 Incident ID,無法比對 provider",
|
||||
"incidentNotFound": "找不到此 Incident 的 provider 關聯",
|
||||
"fetchFailed": "provider 關聯查詢失敗"
|
||||
},
|
||||
"tiles": {
|
||||
"sourceRefs": "Source refs",
|
||||
"sourceRefsValue": "{inbound} 入站 / {outbound} 出站",
|
||||
"sourceRefsDetail": "direct {direct}、candidate {candidate}、applied {applied}",
|
||||
"provider": "Sentry / SigNoz",
|
||||
"providerDetail": "{reason};provider events {providerEvents}",
|
||||
"mcp": "MCP Gateway",
|
||||
"mcpValue": "{success} / {total}",
|
||||
"mcpDetail": "failed {failed}、blocked {blocked}、policy {policy}",
|
||||
"ansible": "Ansible",
|
||||
"ansibleDetail": "mode {mode}、rc {rc}、apply {apply}",
|
||||
"km": "KM",
|
||||
"kmDetail": "verification {verification};next {next}",
|
||||
"handoff": "交接狀態"
|
||||
},
|
||||
"providerEvidence": {
|
||||
"title": "Provider 匹配狀態",
|
||||
"rawIdsHidden": "raw id 已收斂",
|
||||
"summary": "目前判斷:{status}。原因:{reason}。",
|
||||
"counts": "direct {direct} / candidate {candidate} / applied {applied}",
|
||||
"latest": "latest event {event};heartbeat {heartbeat}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Incident Timeline 寫入證據",
|
||||
"summary": "status {status};severity {severity};stages {stages}",
|
||||
"sourceTable": "source_table:{table}",
|
||||
"empty": "此 Incident 尚未回傳 timeline stage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "服務目錄",
|
||||
|
||||
@@ -1327,10 +1327,74 @@
|
||||
"hostStatus": "主機狀態 (FOUR-HOST ARCHITECTURE)",
|
||||
"serviceList": "服務清單",
|
||||
"serviceName": "服務名稱",
|
||||
"serviceHealth": "服務健康",
|
||||
"service": "服務",
|
||||
"status": "狀態",
|
||||
"latency": "延遲",
|
||||
"uptime": "可用率",
|
||||
"lastCheck": "最後檢查"
|
||||
"lastCheck": "最後檢查",
|
||||
"incidentFocus": {
|
||||
"emptyValue": "尚無資料",
|
||||
"title": "焦點 Incident 監控證據鏈",
|
||||
"subtitle": "{incidentId}|{incidentTitle}",
|
||||
"loading": "正在讀取 Incident status-chain 與 timeline...",
|
||||
"loadFailed": "焦點 Incident 資料讀取失敗:{error}",
|
||||
"boundary": "此區塊只讀取監控證據,不會自動標記 Sentry/SigNoz 已匹配,也不會觸發修復或靜音告警。最新 PlayBook:{playbook};executor:{executor}。",
|
||||
"human": {
|
||||
"yes": "需要人工",
|
||||
"no": "未要求人工"
|
||||
},
|
||||
"links": {
|
||||
"workItems": "工作項",
|
||||
"runs": "Runs",
|
||||
"approvals": "審批",
|
||||
"authorizations": "授權",
|
||||
"tickets": "Tickets"
|
||||
},
|
||||
"sourceStatuses": {
|
||||
"linked": "已匹配 provider event",
|
||||
"candidateFound": "找到候選但未套用",
|
||||
"providerFreshNoMatch": "Provider 有心跳但未匹配此 Incident",
|
||||
"missing": "缺少 provider 證據",
|
||||
"noIncidentContext": "缺少 Incident context",
|
||||
"fetchFailed": "讀取 provider 證據失敗"
|
||||
},
|
||||
"sourceReasons": {
|
||||
"providerHeartbeatNoMatch": "Sentry / SigNoz 有心跳,但此 Incident 尚未匹配 provider event",
|
||||
"noMatchingProviderSourceEvent": "沒有找到可對應此 Incident 的 provider source event",
|
||||
"noIncidentIds": "缺少 Incident ID,無法比對 provider",
|
||||
"incidentNotFound": "找不到此 Incident 的 provider 關聯",
|
||||
"fetchFailed": "provider 關聯查詢失敗"
|
||||
},
|
||||
"tiles": {
|
||||
"sourceRefs": "Source refs",
|
||||
"sourceRefsValue": "{inbound} 入站 / {outbound} 出站",
|
||||
"sourceRefsDetail": "direct {direct}、candidate {candidate}、applied {applied}",
|
||||
"provider": "Sentry / SigNoz",
|
||||
"providerDetail": "{reason};provider events {providerEvents}",
|
||||
"mcp": "MCP Gateway",
|
||||
"mcpValue": "{success} / {total}",
|
||||
"mcpDetail": "failed {failed}、blocked {blocked}、policy {policy}",
|
||||
"ansible": "Ansible",
|
||||
"ansibleDetail": "mode {mode}、rc {rc}、apply {apply}",
|
||||
"km": "KM",
|
||||
"kmDetail": "verification {verification};next {next}",
|
||||
"handoff": "交接狀態"
|
||||
},
|
||||
"providerEvidence": {
|
||||
"title": "Provider 匹配狀態",
|
||||
"rawIdsHidden": "raw id 已收斂",
|
||||
"summary": "目前判斷:{status}。原因:{reason}。",
|
||||
"counts": "direct {direct} / candidate {candidate} / applied {applied}",
|
||||
"latest": "latest event {event};heartbeat {heartbeat}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Incident Timeline 寫入證據",
|
||||
"summary": "status {status};severity {severity};stages {stages}",
|
||||
"sourceTable": "source_table:{table}",
|
||||
"empty": "此 Incident 尚未回傳 timeline stage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "服務目錄",
|
||||
|
||||
@@ -11,15 +11,36 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics'
|
||||
import { useHosts } from '@/stores/dashboard.store'
|
||||
import { GlobalPulseChart } from '@/components/charts/global-pulse-chart'
|
||||
import { HostGrid } from '@/components/infra/host-grid'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import {
|
||||
Monitor, RefreshCw, AlertCircle,
|
||||
CheckCircle2, XCircle, Minus,
|
||||
AwoooPStatusChainPanel,
|
||||
type AwoooPStatusChain,
|
||||
} from '@/components/awooop/status-chain'
|
||||
import type { IncidentTimelineResponse } from '@/lib/api-client'
|
||||
import {
|
||||
Activity,
|
||||
BookOpenCheck,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Link2,
|
||||
ListChecks,
|
||||
Minus,
|
||||
Monitor,
|
||||
RadioTower,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
Wrench,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
@@ -43,6 +64,15 @@ interface DashboardResponse {
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface IncidentFocusState {
|
||||
chain: AwoooPStatusChain | null
|
||||
timeline: IncidentTimelineResponse | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type EvidenceTone = 'success' | 'warning' | 'blocked' | 'neutral'
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
@@ -52,6 +82,50 @@ const getApiBaseUrl = () => {
|
||||
return process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, signal?: AbortSignal): Promise<T> {
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function valueOrEmpty(value: unknown, emptyLabel: string) {
|
||||
if (value === null || value === undefined || value === '') return emptyLabel
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined, emptyLabel: string) {
|
||||
if (!value) return emptyLabel
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('zh-TW', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function compactPath(value: string | null | undefined, emptyLabel: string) {
|
||||
if (!value) return emptyLabel
|
||||
if (value.length <= 42) return value
|
||||
return `...${value.slice(-39)}`
|
||||
}
|
||||
|
||||
function evidenceToneClass(tone: EvidenceTone) {
|
||||
if (tone === 'success') return 'border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]'
|
||||
if (tone === 'blocked') return 'border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]'
|
||||
if (tone === 'warning') return 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]'
|
||||
return 'border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]'
|
||||
}
|
||||
|
||||
function providerLabel(provider: string) {
|
||||
const normalized = provider.toLowerCase()
|
||||
if (normalized === 'sentry') return 'Sentry'
|
||||
if (normalized === 'signoz') return 'SigNoz'
|
||||
return provider
|
||||
}
|
||||
|
||||
const STATUS_ICON = {
|
||||
healthy: <CheckCircle2 className="w-4 h-4 text-status-healthy" />,
|
||||
warning: <AlertCircle className="w-4 h-4 text-status-warning" />,
|
||||
@@ -90,6 +164,324 @@ function HealthSummary({ data, t }: { data: DashboardResponse; t: (key: string)
|
||||
)
|
||||
}
|
||||
|
||||
function EvidenceTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
icon: typeof Monitor
|
||||
label: string
|
||||
value: string | number
|
||||
detail: string
|
||||
tone?: EvidenceTone
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0 overflow-hidden border bg-white p-4', evidenceToneClass(tone))}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<p className="truncate text-xs font-semibold uppercase tracking-wider">{label}</p>
|
||||
</div>
|
||||
<p className="mt-3 truncate font-mono text-base font-semibold text-[#141413]" title={String(value)}>
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-[#5f5b52]" title={detail}>
|
||||
{detail}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IncidentObservabilityFocus({
|
||||
incidentId,
|
||||
projectId,
|
||||
state,
|
||||
}: {
|
||||
incidentId: string
|
||||
projectId: string
|
||||
state: IncidentFocusState
|
||||
}) {
|
||||
const t = useTranslations('monitoring.incidentFocus')
|
||||
const chain = state.chain
|
||||
const timeline = state.timeline
|
||||
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 sourceReason = String(sourceCorrelation?.missing_reason ?? '')
|
||||
const directRefTotal = sourceCorrelation?.direct_ref_total ?? 0
|
||||
const candidateTotal = sourceCorrelation?.candidate_total ?? 0
|
||||
const appliedLinkTotal = sourceCorrelation?.applied_link_total ?? 0
|
||||
const providerEventTotal = sourceCorrelation?.provider_event_total ?? 0
|
||||
const mcpGateway = chain?.mcp?.gateway
|
||||
const ansible = chain?.execution?.ansible
|
||||
const timelineStages = timeline?.timeline?.slice(0, 10) ?? []
|
||||
const approvalId = timeline?.approval_ids?.[0]
|
||||
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 reasonLabels: Record<string, string> = {
|
||||
provider_heartbeat_present_but_no_incident_match: t('sourceReasons.providerHeartbeatNoMatch'),
|
||||
no_matching_provider_source_event: t('sourceReasons.noMatchingProviderSourceEvent'),
|
||||
no_incident_ids: t('sourceReasons.noIncidentIds'),
|
||||
incident_not_found: t('sourceReasons.incidentNotFound'),
|
||||
source_correlation_fetch_failed: t('sourceReasons.fetchFailed'),
|
||||
}
|
||||
const sourceStatusLabel = statusLabels[sourceStatus] ?? valueOrEmpty(sourceStatus, emptyLabel)
|
||||
const sourceReasonLabel = reasonLabels[sourceReason] ?? valueOrEmpty(sourceReason, emptyLabel)
|
||||
const providerEntries = Object.entries(sourceCorrelation?.providers ?? {})
|
||||
const sourceTone: EvidenceTone = appliedLinkTotal > 0
|
||||
? 'success'
|
||||
: (directRefTotal > 0 || candidateTotal > 0 ? 'warning' : 'blocked')
|
||||
const mcpTone: EvidenceTone = (mcpGateway?.total ?? 0) > 0
|
||||
? (((mcpGateway?.failed ?? 0) + (mcpGateway?.blocked ?? 0)) > 0 ? 'warning' : 'success')
|
||||
: 'neutral'
|
||||
const ansibleTone: EvidenceTone = ansible?.applied || (ansible?.apply_total ?? 0) > 0
|
||||
? 'success'
|
||||
: (ansible?.considered || (ansible?.record_total ?? 0) > 0 ? 'warning' : 'neutral')
|
||||
const handoffTone: EvidenceTone = chain?.needs_human ? 'blocked' : (chain ? 'success' : 'neutral')
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: t('links.workItems'),
|
||||
href: `/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: ListChecks,
|
||||
},
|
||||
{
|
||||
label: t('links.runs'),
|
||||
href: `/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: Activity,
|
||||
},
|
||||
{
|
||||
label: t('links.approvals'),
|
||||
href: `/awooop/approvals?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: t('links.authorizations'),
|
||||
href: `/authorizations?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}${approvalId ? `&approval_id=${encodeURIComponent(approvalId)}` : ''}` as never,
|
||||
Icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
label: t('links.tickets'),
|
||||
href: `/tickets?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never,
|
||||
Icon: GitBranch,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="monitoring-incident-focus"
|
||||
className="mb-6 overflow-hidden 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="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn('inline-flex h-8 w-8 items-center justify-center border', evidenceToneClass(handoffTone))}>
|
||||
{chain?.needs_human ? (
|
||||
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-[#141413]">
|
||||
{t('title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[#77736a]">
|
||||
{t('subtitle', {
|
||||
incidentId,
|
||||
incidentTitle: timeline?.title ?? valueOrEmpty(chain?.source_id, emptyLabel),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{navItems.map(({ label, href, Icon }) => (
|
||||
<Link
|
||||
key={label}
|
||||
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}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="border-b border-[#e0ddd4] bg-[#fff0ef] px-4 py-3 text-xs font-semibold text-[#9f2f25]" role="alert">
|
||||
{t('loadFailed', { error: state.error })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.loading && !chain && !timeline && (
|
||||
<div className="border-b border-[#e0ddd4] px-4 py-3 text-xs text-[#77736a]">
|
||||
{t('loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-w-0 gap-px bg-[#e0ddd4] md:grid-cols-3 xl:grid-cols-6">
|
||||
<EvidenceTile
|
||||
icon={Link2}
|
||||
label={t('tiles.sourceRefs')}
|
||||
value={t('tiles.sourceRefsValue', {
|
||||
inbound: chain?.source_refs?.inbound_total ?? 0,
|
||||
outbound: chain?.source_refs?.outbound_total ?? 0,
|
||||
})}
|
||||
detail={t('tiles.sourceRefsDetail', {
|
||||
direct: directRefTotal,
|
||||
candidate: candidateTotal,
|
||||
applied: appliedLinkTotal,
|
||||
})}
|
||||
tone={sourceTone}
|
||||
/>
|
||||
<EvidenceTile
|
||||
icon={RadioTower}
|
||||
label={t('tiles.provider')}
|
||||
value={sourceStatusLabel}
|
||||
detail={t('tiles.providerDetail', {
|
||||
reason: sourceReasonLabel,
|
||||
providerEvents: providerEventTotal,
|
||||
})}
|
||||
tone={sourceTone}
|
||||
/>
|
||||
<EvidenceTile
|
||||
icon={Activity}
|
||||
label={t('tiles.mcp')}
|
||||
value={t('tiles.mcpValue', {
|
||||
success: mcpGateway?.success ?? 0,
|
||||
total: mcpGateway?.total ?? 0,
|
||||
})}
|
||||
detail={t('tiles.mcpDetail', {
|
||||
failed: mcpGateway?.failed ?? 0,
|
||||
blocked: mcpGateway?.blocked ?? 0,
|
||||
policy: mcpGateway?.policy_enforced_total ?? 0,
|
||||
})}
|
||||
tone={mcpTone}
|
||||
/>
|
||||
<EvidenceTile
|
||||
icon={Wrench}
|
||||
label={t('tiles.ansible')}
|
||||
value={valueOrEmpty(ansible?.latest_status ?? chain?.execution?.latest_status, emptyLabel)}
|
||||
detail={t('tiles.ansibleDetail', {
|
||||
mode: valueOrEmpty(ansible?.latest_execution_mode, emptyLabel),
|
||||
rc: valueOrEmpty(ansible?.latest_returncode, emptyLabel),
|
||||
apply: valueOrEmpty(ansible?.latest_apply_executed, emptyLabel),
|
||||
})}
|
||||
tone={ansibleTone}
|
||||
/>
|
||||
<EvidenceTile
|
||||
icon={BookOpenCheck}
|
||||
label={t('tiles.km')}
|
||||
value={chain?.evidence?.knowledge_entries ?? 0}
|
||||
detail={t('tiles.kmDetail', {
|
||||
verification: valueOrEmpty(chain?.verification, emptyLabel),
|
||||
next: valueOrEmpty(chain?.next_step ?? chain?.operator_outcome?.next_action, emptyLabel),
|
||||
})}
|
||||
tone={(chain?.evidence?.knowledge_entries ?? 0) > 0 ? 'success' : 'neutral'}
|
||||
/>
|
||||
<EvidenceTile
|
||||
icon={chain?.needs_human ? TriangleAlert : CheckCircle2}
|
||||
label={t('tiles.handoff')}
|
||||
value={chain?.needs_human ? t('human.yes') : t('human.no')}
|
||||
detail={valueOrEmpty(chain?.operator_outcome?.summary_zh ?? chain?.operator_outcome?.human_action_reason, emptyLabel)}
|
||||
tone={handoffTone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-px bg-[#e0ddd4] lg:grid-cols-2">
|
||||
<div className="min-w-0 bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold text-[#141413]">{t('providerEvidence.title')}</h4>
|
||||
<span className="text-xs font-semibold text-[#77736a]">
|
||||
{t('providerEvidence.rawIdsHidden')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
|
||||
{t('providerEvidence.summary', {
|
||||
status: sourceStatusLabel,
|
||||
reason: sourceReasonLabel,
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{(providerEntries.length > 0 ? providerEntries : [['sentry', null], ['signoz', null]] as const).map(([provider, stats]) => (
|
||||
<div key={provider} className="border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<p className="text-xs font-semibold text-[#141413]">{providerLabel(provider)}</p>
|
||||
<p className="mt-2 font-mono text-xs text-[#3f3a32]">
|
||||
{t('providerEvidence.counts', {
|
||||
direct: stats?.direct_ref_total ?? 0,
|
||||
candidate: stats?.candidate_total ?? 0,
|
||||
applied: stats?.applied_link_total ?? 0,
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[#77736a]">
|
||||
{t('providerEvidence.latest', {
|
||||
event: formatDateTime(stats?.latest_event_at, emptyLabel),
|
||||
heartbeat: formatDateTime(stats?.latest_heartbeat_at, emptyLabel),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 bg-white px-4 py-4">
|
||||
<h4 className="text-sm font-semibold text-[#141413]">{t('timeline.title')}</h4>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
|
||||
{t('timeline.summary', {
|
||||
status: valueOrEmpty(timeline?.status, emptyLabel),
|
||||
severity: valueOrEmpty(timeline?.severity, emptyLabel),
|
||||
stages: timeline?.timeline?.length ?? 0,
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{timelineStages.map((stage) => (
|
||||
<div key={`${stage.stage}-${stage.status}-${stage.timestamp ?? ''}`} className="border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs font-semibold text-[#141413]" title={stage.label || stage.stage}>
|
||||
{stage.label || stage.stage}
|
||||
</p>
|
||||
<span className="shrink-0 font-mono text-[11px] text-[#77736a]">{stage.status}</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-xs text-[#5f5b52]" title={valueOrEmpty(stage.source_table, emptyLabel)}>
|
||||
{t('timeline.sourceTable', { table: valueOrEmpty(stage.source_table, emptyLabel) })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{timelineStages.length === 0 && (
|
||||
<div className="border border-[#e0ddd4] bg-[#faf9f3] p-3 text-xs text-[#77736a]">
|
||||
{t('timeline.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs leading-5 text-[#5f5b52]">
|
||||
{t('boundary', {
|
||||
playbook: compactPath(ansible?.latest_playbook_path, emptyLabel),
|
||||
executor: valueOrEmpty(chain?.execution?.latest_executor, emptyLabel),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden">
|
||||
<AwoooPStatusChainPanel chain={chain} compact className="border-x-0 border-b-0" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Panel 元件 (不含 AppLayout)
|
||||
// =============================================================================
|
||||
@@ -99,6 +491,9 @@ export function MonitoringPanel() {
|
||||
const tNav = useTranslations('nav')
|
||||
const tCommon = useTranslations('common')
|
||||
const tAlerts = useTranslations('alerts')
|
||||
const searchParams = useSearchParams()
|
||||
const projectId = searchParams.get('project_id') ?? 'awoooi'
|
||||
const incidentId = searchParams.get('incident_id')
|
||||
const hosts = useHosts()
|
||||
|
||||
const { metrics, isLoading: metricsLoading } = useGlobalPulseMetrics({
|
||||
@@ -109,7 +504,14 @@ export function MonitoringPanel() {
|
||||
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null)
|
||||
const [dashLoading, setDashLoading] = useState(true)
|
||||
const [dashError, setDashError] = useState<string | null>(null)
|
||||
const [incidentFocus, setIncidentFocus] = useState<IncidentFocusState>({
|
||||
chain: null,
|
||||
timeline: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const focusAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const fetchDashboard = useCallback(async () => {
|
||||
const base = getApiBaseUrl()
|
||||
@@ -133,6 +535,48 @@ export function MonitoringPanel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchIncidentFocus = useCallback(async () => {
|
||||
const base = getApiBaseUrl()
|
||||
if (!base || !incidentId) {
|
||||
focusAbortRef.current?.abort()
|
||||
setIncidentFocus({ chain: null, timeline: null, loading: false, error: null })
|
||||
return
|
||||
}
|
||||
|
||||
focusAbortRef.current?.abort()
|
||||
const ctrl = new AbortController()
|
||||
focusAbortRef.current = ctrl
|
||||
|
||||
setIncidentFocus((current) => ({ ...current, loading: true, error: null }))
|
||||
const encodedProjectId = encodeURIComponent(projectId)
|
||||
const encodedIncidentId = encodeURIComponent(incidentId)
|
||||
const [statusChainResult, timelineResult] = await Promise.allSettled([
|
||||
fetchJson<AwoooPStatusChain>(
|
||||
`${base}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}`,
|
||||
ctrl.signal
|
||||
),
|
||||
fetchJson<IncidentTimelineResponse>(
|
||||
`${base}/api/v1/incidents/${encodedIncidentId}/timeline`,
|
||||
ctrl.signal
|
||||
),
|
||||
])
|
||||
|
||||
if (ctrl.signal.aborted) return
|
||||
|
||||
const chain = statusChainResult.status === 'fulfilled' ? statusChainResult.value : null
|
||||
const timeline = timelineResult.status === 'fulfilled' ? timelineResult.value : null
|
||||
const errors = [statusChainResult, timelineResult]
|
||||
.filter((item): item is PromiseRejectedResult => item.status === 'rejected')
|
||||
.map((item) => item.reason instanceof Error ? item.reason.message : String(item.reason))
|
||||
|
||||
setIncidentFocus({
|
||||
chain,
|
||||
timeline,
|
||||
loading: false,
|
||||
error: errors.length > 0 ? errors.join(' / ') : null,
|
||||
})
|
||||
}, [incidentId, projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard()
|
||||
const id = setInterval(fetchDashboard, 30000)
|
||||
@@ -142,7 +586,24 @@ export function MonitoringPanel() {
|
||||
}
|
||||
}, [fetchDashboard])
|
||||
|
||||
const isLoading = metricsLoading || dashLoading
|
||||
useEffect(() => {
|
||||
fetchIncidentFocus()
|
||||
if (!incidentId) return () => {
|
||||
focusAbortRef.current?.abort()
|
||||
}
|
||||
const id = setInterval(fetchIncidentFocus, 30000)
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
focusAbortRef.current?.abort()
|
||||
}
|
||||
}, [fetchIncidentFocus, incidentId])
|
||||
|
||||
const isLoading = metricsLoading || dashLoading || incidentFocus.loading
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchDashboard()
|
||||
fetchIncidentFocus()
|
||||
}, [fetchDashboard, fetchIncidentFocus])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -158,7 +619,7 @@ export function MonitoringPanel() {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchDashboard}
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||||
@@ -172,6 +633,14 @@ export function MonitoringPanel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{incidentId && (
|
||||
<IncidentObservabilityFocus
|
||||
incidentId={incidentId}
|
||||
projectId={projectId}
|
||||
state={incidentFocus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gold Metrics */}
|
||||
{metrics && <GlobalPulseChart metrics={metrics} />}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user