feat(web): drive incident flow summaries from status chain
This commit is contained in:
@@ -420,6 +420,11 @@
|
||||
"aiProposalPreview": "AI Proposal: {action}",
|
||||
"flowCurrentLabel": "Current stage",
|
||||
"flowNextLabel": "Next step",
|
||||
"flowSourceLabel": "Source",
|
||||
"flowSourceTruthChain": "truth-chain / ADR-100",
|
||||
"flowSourceHeuristic": "incident status heuristic",
|
||||
"flowVerdictLabel": "Verdict",
|
||||
"flowTruthChainCurrent": "{stage} / {status}",
|
||||
"flowComplete": "Complete",
|
||||
"flowStages": {
|
||||
"alert": "Alert received",
|
||||
|
||||
@@ -421,6 +421,11 @@
|
||||
"aiProposalPreview": "AI 提案:{action}",
|
||||
"flowCurrentLabel": "目前階段",
|
||||
"flowNextLabel": "下一步",
|
||||
"flowSourceLabel": "來源",
|
||||
"flowSourceTruthChain": "truth-chain / ADR-100",
|
||||
"flowSourceHeuristic": "事件狀態推導",
|
||||
"flowVerdictLabel": "判定",
|
||||
"flowTruthChainCurrent": "{stage} / {status}",
|
||||
"flowComplete": "已完成",
|
||||
"flowStages": {
|
||||
"alert": "告警收到",
|
||||
|
||||
@@ -34,8 +34,10 @@ import { PendingApprovalsCard } from '@/components/shared/pending-approvals-card
|
||||
import { AIModelStatus } from '@/components/shared/ai-model-status'
|
||||
import { FlywheelKPICard } from '@/components/dashboard/flywheel-kpi-card'
|
||||
import { AutomationEvidenceCard } from '@/components/dashboard/automation-evidence-card'
|
||||
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
const STATUS_CHAIN_PREFETCH_LIMIT = 25
|
||||
|
||||
// =============================================================================
|
||||
// Tab 2: 告警 & 授權 (串接真實 API)
|
||||
@@ -128,7 +130,9 @@ function ActivityStreamTab() {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
setEvents(prev => [{ ...data, _time: new Date().toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }, ...prev].slice(0, 50))
|
||||
} catch {}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
es.onerror = () => setConnected(false)
|
||||
return () => es.close()
|
||||
@@ -600,10 +604,59 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
incidents,
|
||||
isLoading: isIncidentsLoading,
|
||||
error: incidentsError,
|
||||
lastUpdated: incidentsLastUpdated,
|
||||
} = useIncidents({
|
||||
pollInterval: 15000,
|
||||
enablePolling: true,
|
||||
})
|
||||
const statusChainIncidentKey = incidents
|
||||
?.slice(0, STATUS_CHAIN_PREFETCH_LIMIT)
|
||||
.map(incident => incident.incident_id)
|
||||
.join('|') ?? ''
|
||||
const [statusChains, setStatusChains] = useState<Record<string, AwoooPStatusChain | null>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const incidentIds = statusChainIncidentKey
|
||||
? statusChainIncidentKey.split('|').filter(Boolean)
|
||||
: []
|
||||
if (incidentIds.length === 0) {
|
||||
setStatusChains({})
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), 12000)
|
||||
|
||||
Promise.all(
|
||||
incidentIds.map(async (incidentId): Promise<[string, AwoooPStatusChain | null]> => {
|
||||
const params = new URLSearchParams({ project_id: 'awoooi' })
|
||||
params.append('incident_id', incidentId)
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/platform/status-chain?${params.toString()}`, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) return [incidentId, null]
|
||||
return [incidentId, await response.json() as AwoooPStatusChain]
|
||||
} catch {
|
||||
return [incidentId, null]
|
||||
}
|
||||
})
|
||||
)
|
||||
.then(entries => {
|
||||
if (controller.signal.aborted) return
|
||||
setStatusChains(Object.fromEntries(entries))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setStatusChains({})
|
||||
})
|
||||
.finally(() => window.clearTimeout(timeout))
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
controller.abort()
|
||||
}
|
||||
}, [statusChainIncidentKey, incidentsLastUpdated])
|
||||
|
||||
// ── Metrics 計算 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -889,6 +942,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
key={incident.incident_id}
|
||||
incident={incident}
|
||||
decision={incident.decision}
|
||||
statusChain={statusChains[incident.incident_id] ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { apiClient } from '@/lib/api-client'
|
||||
import { CURRENT_USER } from '@/lib/constants'
|
||||
import { useCSRF } from '@/hooks/useCSRF'
|
||||
import { FlowPipeline, type FlowStage } from './flow-pipeline'
|
||||
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -32,6 +33,7 @@ const EXECUTION_TIMEOUT_MS = 30000
|
||||
export interface IncidentCardProps {
|
||||
incident: IncidentResponse
|
||||
decision?: DecisionInfo | null
|
||||
statusChain?: AwoooPStatusChain | null
|
||||
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
|
||||
}
|
||||
|
||||
@@ -83,6 +85,58 @@ function nextFlowStage(stage: FlowStage, isResolved: boolean): FlowStage | null
|
||||
return FLOW_STAGE_ORDER[index + 1]
|
||||
}
|
||||
|
||||
function statusChainFlowStage(chain?: AwoooPStatusChain | null): FlowStage | null {
|
||||
if (!chain || chain.fetch_error) return null
|
||||
const currentStage = String(chain.current_stage ?? '').toLowerCase()
|
||||
const repairState = String(chain.repair_state ?? '').toLowerCase()
|
||||
const verdict = String(chain.verdict ?? '').toLowerCase()
|
||||
const nextStep = String(chain.next_step ?? '').toLowerCase()
|
||||
|
||||
if (verdict === 'auto_repaired_verified' || repairState === 'auto_repaired_verified') {
|
||||
return 'resolved'
|
||||
}
|
||||
if (
|
||||
currentStage.includes('execution') ||
|
||||
repairState.startsWith('executed') ||
|
||||
nextStep.includes('verify_execution')
|
||||
) {
|
||||
return 'execution'
|
||||
}
|
||||
if (
|
||||
currentStage.includes('approval') ||
|
||||
repairState.includes('manual_required') ||
|
||||
nextStep.includes('manual') ||
|
||||
chain.needs_human === true
|
||||
) {
|
||||
return 'approval'
|
||||
}
|
||||
if (
|
||||
currentStage.includes('proposal') ||
|
||||
currentStage.includes('safe') ||
|
||||
currentStage.includes('target') ||
|
||||
currentStage.includes('blast') ||
|
||||
currentStage.includes('llm')
|
||||
) {
|
||||
return 'proposal'
|
||||
}
|
||||
if (
|
||||
currentStage.includes('analysis') ||
|
||||
currentStage.includes('investigator') ||
|
||||
currentStage.includes('router')
|
||||
) {
|
||||
return 'analysis'
|
||||
}
|
||||
if (currentStage.includes('webhook') || currentStage.includes('source') || currentStage.includes('alert')) {
|
||||
return 'alert'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function chainValue(value: string | null | undefined, fallback = '--'): string {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || fallback
|
||||
}
|
||||
|
||||
/** 格式化持續時間 */
|
||||
function formatDuration(createdAt: string | undefined): string {
|
||||
if (!createdAt) return '--'
|
||||
@@ -218,7 +272,7 @@ function useApprovalAction(
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function IncidentCard({ incident, decision, onApprovalChange }: IncidentCardProps) {
|
||||
export function IncidentCard({ incident, decision, statusChain, onApprovalChange }: IncidentCardProps) {
|
||||
const t = useTranslations('incident.card')
|
||||
const { csrfToken } = useCSRF()
|
||||
|
||||
@@ -232,9 +286,14 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
|
||||
const incidentStatus = incident.status as string
|
||||
const sev = incident.severity as keyof typeof SEV_CONFIG
|
||||
const sevCfg = SEV_CONFIG[sev] ?? SEV_CONFIG.P3
|
||||
const flowStage = toFlowStage(incidentStatus, incident.severity, decision)
|
||||
const heuristicFlowStage = toFlowStage(incidentStatus, incident.severity, decision)
|
||||
const statusChainStage = statusChainFlowStage(statusChain)
|
||||
const flowStage = statusChainStage ?? heuristicFlowStage
|
||||
const isResolved = incidentStatus === 'resolved' || incidentStatus === 'closed'
|
||||
const nextStage = nextFlowStage(flowStage, isResolved)
|
||||
const isTruthChainResolved = statusChain?.repair_state === 'auto_repaired_verified' || statusChain?.verdict === 'auto_repaired_verified'
|
||||
const isFlowResolved = isResolved || isTruthChainResolved
|
||||
const nextStage = nextFlowStage(flowStage, isFlowResolved)
|
||||
const hasTruthChain = Boolean(statusChain && !statusChain.fetch_error && statusChain.source_id)
|
||||
const flowStageLabels: Record<FlowStage, string> = {
|
||||
alert: t('flowStages.alert'),
|
||||
detection: t('flowStages.detection'),
|
||||
@@ -244,6 +303,16 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
|
||||
execution: t('flowStages.execution'),
|
||||
resolved: t('flowStages.resolved'),
|
||||
}
|
||||
const currentStageText = hasTruthChain
|
||||
? t('flowTruthChainCurrent', {
|
||||
stage: chainValue(statusChain?.current_stage),
|
||||
status: chainValue(statusChain?.stage_status),
|
||||
})
|
||||
: flowStageLabels[flowStage]
|
||||
const nextStageText = hasTruthChain
|
||||
? chainValue(statusChain?.next_step)
|
||||
: nextStage ? flowStageLabels[nextStage] : t('flowComplete')
|
||||
const flowSourceText = hasTruthChain ? t('flowSourceTruthChain') : t('flowSourceHeuristic')
|
||||
|
||||
const serviceName = incident.affected_services?.[0] ?? '--'
|
||||
const duration = formatDuration(incident.created_at)
|
||||
@@ -461,10 +530,11 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
|
||||
</div>
|
||||
|
||||
{/* 流程狀態圖 */}
|
||||
<FlowPipeline activeStage={flowStage} isResolved={isResolved} severity={sev} />
|
||||
<FlowPipeline activeStage={flowStage} isResolved={isFlowResolved} severity={sev} />
|
||||
|
||||
<div
|
||||
data-testid="incident-flow-summary"
|
||||
data-flow-source={hasTruthChain ? 'truth-chain' : 'heuristic'}
|
||||
style={{
|
||||
margin: '0 14px 8px',
|
||||
padding: '7px 9px',
|
||||
@@ -480,12 +550,24 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{t('flowCurrentLabel')}: <strong style={{ color: sevCfg.labelColor }}>{flowStageLabels[flowStage]}</strong>
|
||||
{t('flowCurrentLabel')}: <strong style={{ color: sevCfg.labelColor }}>{currentStageText}</strong>
|
||||
</span>
|
||||
<span style={{ color: '#b0ad9f' }}>/</span>
|
||||
<span>
|
||||
{t('flowNextLabel')}: <strong style={{ color: '#141413' }}>{nextStage ? flowStageLabels[nextStage] : t('flowComplete')}</strong>
|
||||
{t('flowNextLabel')}: <strong style={{ color: '#141413' }}>{nextStageText}</strong>
|
||||
</span>
|
||||
<span style={{ color: '#b0ad9f' }}>/</span>
|
||||
<span>
|
||||
{t('flowSourceLabel')}: <strong style={{ color: '#555550' }}>{flowSourceText}</strong>
|
||||
</span>
|
||||
{hasTruthChain && (
|
||||
<>
|
||||
<span style={{ color: '#b0ad9f' }}>/</span>
|
||||
<span>
|
||||
{t('flowVerdictLabel')}: <strong style={{ color: statusChain?.needs_human ? '#9f2f25' : '#17602a' }}>{chainValue(statusChain?.verdict)}</strong>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Impact 指標列 */}
|
||||
|
||||
Reference in New Issue
Block a user