diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 2b748737..4c145e24 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -277,7 +277,9 @@ "brainAnalyzing": ">_ Brain analyzing...", "decisionReady": ">_ Decision ready (Tier {tier})", "waitingCommander": ">_ Awaiting commander approval (Tier {tier})", - "suggestedAction": "> Suggested action:" + "suggestedAction": "> Suggested action:", + "authorize": "Authorize", + "reject": "Reject" } }, "status": { @@ -554,4 +556,4 @@ } } } -} +} \ No newline at end of file diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index aa6de779..dab37615 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -278,7 +278,9 @@ "brainAnalyzing": ">_ 大腦分析中...", "decisionReady": ">_ 決策就緒 (Tier {tier})", "waitingCommander": ">_ 等待統帥親核 (Tier {tier})", - "suggestedAction": "> 建議行動:" + "suggestedAction": "> 建議行動:", + "authorize": "授權", + "reject": "拒絕" } }, "status": { @@ -555,4 +557,4 @@ } } } -} +} \ No newline at end of file diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index fb7f64f7..95017317 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -1,273 +1,382 @@ 'use client' /** - * IncidentCard - Phase 7 事件卡片 - * ================================ + * IncidentCard - AI中心 v6 事件卡片 + * ===================================== + * Anthropic Warmth 米色系 + OpenClaw Blue + * - 頂邊條顏色 = 嚴重度(P0=紅/P2=橙/P3=藍/OK=綠) + * - FlowPipeline 7節點 + Q版龍蝦 + * - 授權邏輯從 DualStateIncidentCard (Phase 6.5) 移植 + * handleApprove / handleReject / CSRF / 30s 超時重試 * - * Nothing.tech 視覺規範: - * - 純白底色 (bg-white) - * - 極細淺灰邊框 (border border-gray-200) - * - 無圓角或微圓角 (rounded-sm) - * - 嚴禁陰影 (shadow-none) - * - * 統帥鐵律: 禁止假數據!所有資料必須來自真實 API + * 統帥鐵律: 禁止假數據!無數據顯示 "--" + * @deprecated dual-state-incident-card.tsx (Phase 6.5 舊風格,此元件取代後刪除) */ +import React, { useState, useCallback, useRef, useEffect } from 'react' import { useTranslations } from 'next-intl' -import { cn } from '@/lib/utils' -import type { IncidentResponse } from '@/lib/api-client' -import { AlertTriangle, Clock, Server, ChevronRight } from 'lucide-react' +import type { IncidentResponse, DecisionInfo } from '@/lib/api-client' +import { apiClient } from '@/lib/api-client' +import { useCSRF } from '@/hooks/useCSRF' +import { FlowPipeline, type FlowStage } from './flow-pipeline' // ============================================================================= -// Severity Config +// Types // ============================================================================= -const SEVERITY_CONFIG = { - P0: { - label: 'P0', - color: 'text-red-600', - bgColor: 'bg-red-50', - borderColor: 'border-red-200', - dotColor: 'bg-red-500', - }, - P1: { - label: 'P1', - color: 'text-orange-600', - bgColor: 'bg-orange-50', - borderColor: 'border-orange-200', - dotColor: 'bg-orange-500', - }, - P2: { - label: 'P2', - color: 'text-amber-600', - bgColor: 'bg-amber-50', - borderColor: 'border-amber-200', - dotColor: 'bg-amber-500', - }, - P3: { - label: 'P3', - color: 'text-gray-600', - bgColor: 'bg-gray-50', - borderColor: 'border-gray-200', - dotColor: 'bg-gray-400', - }, +type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error' | 'timeout' + +const EXECUTION_TIMEOUT_MS = 30000 + +export interface IncidentCardProps { + incident: IncidentResponse + decision?: DecisionInfo | null + onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void +} + +// ============================================================================= +// Severity / Status Config +// ============================================================================= + +const SEV_CONFIG = { + P0: { barColor: '#cc2200', label: 'P0', labelBg: '#ffeaea', labelColor: '#cc2200' }, + P1: { barColor: '#d97757', label: 'P1', labelBg: '#fff0ea', labelColor: '#b05030' }, + P2: { barColor: '#4A90D9', label: 'P2', labelBg: '#eaf4ff', labelColor: '#2a6cb0' }, + P3: { barColor: '#22C55E', label: 'P3', labelBg: '#eafff2', labelColor: '#166534' }, } as const -const STATUS_CONFIG = { - investigating: { - label: 'investigating', - color: 'text-blue-600', - bgColor: 'bg-blue-50', - }, - mitigating: { - label: 'mitigating', - color: 'text-purple-600', - bgColor: 'bg-purple-50', - }, - resolved: { - label: 'resolved', - color: 'text-green-600', - bgColor: 'bg-green-50', - }, - closed: { - label: 'closed', - color: 'text-gray-500', - bgColor: 'bg-gray-50', - }, -} as const +/** 根據 incident status 對應 FlowStage */ +function toFlowStage(status: string, severity: string): FlowStage { + switch (status) { + case 'new': return 'alert' + case 'investigating': return 'detection' + case 'analyzing': return 'analysis' + case 'proposal_generated': return 'proposal' + case 'waiting_approval': return 'approval' + case 'executing': return 'execution' + case 'resolved': return 'resolved' + default: return severity === 'P0' ? 'alert' : 'detection' + } +} + +/** 格式化持續時間 */ +function formatDuration(createdAt: string | undefined): string { + if (!createdAt) return '--' + try { + const ms = Date.now() - new Date(createdAt).getTime() + const mins = Math.floor(ms / 60000) + if (mins < 60) return `${mins}m` + return `${Math.floor(mins / 60)}h${mins % 60}m` + } catch { + return '--' + } +} // ============================================================================= // Component // ============================================================================= -interface IncidentCardProps { - incident: IncidentResponse - onClick?: () => void - className?: string -} +export function IncidentCard({ incident, decision, onApprovalChange }: IncidentCardProps) { + const t = useTranslations('incident.card') + const { csrfToken } = useCSRF() -export function IncidentCard({ - incident, - onClick, - className, -}: IncidentCardProps) { - const t = useTranslations('incident') + const [buttonState, setButtonState] = useState('idle') + const [errorMessage, setErrorMessage] = useState(null) + const [currentProposalId, setCurrentProposalId] = useState(null) + const [aiExpanded, setAiExpanded] = useState(false) + const timeoutRef = useRef(null) - const severityConfig = SEVERITY_CONFIG[incident.severity] - const statusConfig = STATUS_CONFIG[incident.status] + useEffect(() => { + return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) } + }, []) - // 格式化時間 - const createdAt = new Date(incident.created_at) - const timeAgo = getTimeAgo(createdAt) + 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) + const isResolved = incidentStatus === 'resolved' + const isWaitingApproval = incidentStatus === 'waiting_approval' - return ( -
- {/* Header: ID + Severity Badge */} -
-
- {/* Severity Dot */} - - {/* Incident ID */} - - {incident.incident_id} + const serviceName = incident.affected_services?.[0] ?? '--' + const duration = formatDuration(incident.created_at) + const isDecisionReady = decision?.state === 'ready' || !!currentProposalId + const isAnalyzing = decision?.state === 'analyzing' + const decisionAction = decision?.proposal_data?.action ?? '' + const decisionReasoning = decision?.proposal_data?.reasoning ?? '' + + // ── handleApprove(移植自 DualStateIncidentCard Phase 6.5)────────────── + const handleApprove = useCallback(async () => { + if (!isDecisionReady || buttonState === 'loading') return + setButtonState('loading') + setErrorMessage(null) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setButtonState('timeout') + setErrorMessage(t('timeoutMessage')) + }, EXECUTION_TIMEOUT_MS) + + try { + let approvalId = currentProposalId + if (!approvalId && decision?.token) { + const proposalResult = await apiClient.generateProposal(incident.incident_id) + if (!proposalResult.success || !proposalResult.proposal) { + throw new Error(proposalResult.message || 'Failed to generate proposal') + } + approvalId = proposalResult.proposal.id + setCurrentProposalId(approvalId) + } + if (!approvalId) throw new Error('No approval ID available') + const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via AI Center', csrfToken) + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } + const approvalStatus = result.approval?.status?.toLowerCase() + if (approvalStatus === 'approved') { + setButtonState('approved') + onApprovalChange?.(approvalId, 'approved') + } else { + setButtonState('idle') + } + } catch (error) { + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } + setButtonState('error') + setErrorMessage(error instanceof Error ? error.message : String(error)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken]) + + // ── handleReject(移植自 DualStateIncidentCard Phase 6.5)──────────────── + const handleReject = useCallback(async () => { + if (!isDecisionReady || buttonState === 'loading') return + setButtonState('loading') + setErrorMessage(null) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setButtonState('timeout') + setErrorMessage(t('timeoutMessage')) + }, EXECUTION_TIMEOUT_MS) + + try { + let approvalId = currentProposalId + if (!approvalId && decision?.token) { + const proposalResult = await apiClient.generateProposal(incident.incident_id) + if (proposalResult.success && proposalResult.proposal) { + approvalId = proposalResult.proposal.id + setCurrentProposalId(approvalId) + } + } + if (!approvalId) throw new Error('No approval ID available') + await apiClient.rejectApproval(approvalId, 'Rejected via AI Center', csrfToken) + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } + setButtonState('rejected') + onApprovalChange?.(approvalId, 'rejected') + } catch (error) { + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } + setButtonState('error') + setErrorMessage(error instanceof Error ? error.message : String(error)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken]) + + // ── 授權按鈕渲染 ─────────────────────────────────────────────────────────── + const renderApproveButtons = () => { + switch (buttonState) { + case 'loading': + return ( + + + {t('executing')} -
- - {/* Severity Badge (Dot Matrix 靈魂注入) */} - - {severityConfig.label} - -
- - {/* Status Badge */} -
- - {t(`status.${statusConfig.label}`)} - -
- - {/* Affected Services */} -
-
- - {t('affectedServices')} -
-
- {incident.affected_services.slice(0, 3).map((service) => ( - ✓ {t('approved')} + case 'rejected': + return ✗ {t('rejected')} + case 'error': + case 'timeout': + return ( +
+ + {buttonState === 'timeout' ? t('timeout') : t('error')} + +
-
+ {t('retry')} + +
+ ) + default: + return ( +
+ + + {isAnalyzing && } +
+ ) + } + } - {/* Footer: Stats + Time */} -
-
- {/* Signal Count */} - - - {incident.signal_count} {t('signals')} - - {/* Proposal Count */} - {incident.proposal_count > 0 && ( - - {incident.proposal_count} {t('proposals')} - - )} -
- - {/* Time + Arrow */} -
- - {timeAgo} - {onClick && } -
-
-
- ) -} - -// ============================================================================= -// Grid Container -// ============================================================================= - -interface IncidentCardGridProps { - children: React.ReactNode - className?: string -} - -export function IncidentCardGrid({ children, className }: IncidentCardGridProps) { return ( -
+ {/* 頂邊條 3px */} +
+ + {/* 標頭列:嚴重度 + 服務標籤 + 時間 */} +
+ + {sevCfg.label} + + + {serviceName} + + + {duration} + +
+ + {/* 事件標題 */} +
+ {(incident as IncidentResponse & { description?: string }).description ?? '--'} +
+ + {/* INC-ID */} +
+ {incident.incident_id} +
+ + {/* 流程狀態圖 */} + + + {/* Impact 指標列 */} +
+ + 影響服務 {incident.affected_services?.length ?? 0} + + + 信號數 {incident.signal_count ?? '--'} + + + 狀態 {incident.status} + +
+ + {/* AI 提案行(可展開)*/} + {decisionAction && ( + <> +
setAiExpanded(v => !v)} + style={{ + margin: '0 10px 6px', + padding: '5px 8px', + background: '#f0efe8', + border: '0.5px solid #e0ddd4', + borderLeft: '3px solid rgba(217,119,87,0.5)', + borderRadius: 6, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 5, + fontSize: 9, + color: '#87867f', + }} + > + + AI 提案: {decisionAction.slice(0, 50)}{decisionAction.length > 50 ? '...' : ''} + {aiExpanded ? '▲' : '▼'} +
+ {aiExpanded && ( +
+
{decisionAction}
+ {decisionReasoning && ( +
+ 💡 {decisionReasoning.slice(0, 150)}{decisionReasoning.length > 150 ? '...' : ''} +
+ )} +
+ )} + )} - > - {children} + + {/* 授權按鈕(僅 waiting_approval 狀態)*/} + {isWaitingApproval && ( +
+ {renderApproveButtons()} + {errorMessage && ( + + {errorMessage} + + )} +
+ )} + + {/* spin keyframe */} +
) } -// ============================================================================= -// Empty State -// ============================================================================= - -export function IncidentEmptyState() { - const t = useTranslations('incident') - - return ( -
-
- -
-

- {t('emptyState')} -

-

- {t('emptyStateDescription')} -

-
- ) -} - -// ============================================================================= -// Utilities -// ============================================================================= - -function getTimeAgo(date: Date): string { - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMins = Math.floor(diffMs / 60000) - const diffHours = Math.floor(diffMs / 3600000) - const diffDays = Math.floor(diffMs / 86400000) - - if (diffMins < 1) return 'just now' - if (diffMins < 60) return `${diffMins}m ago` - if (diffHours < 24) return `${diffHours}h ago` - return `${diffDays}d ago` -} +export default IncidentCard diff --git a/apps/web/src/components/incident/index.ts b/apps/web/src/components/incident/index.ts index 732af21e..4d9c5f53 100644 --- a/apps/web/src/components/incident/index.ts +++ b/apps/web/src/components/incident/index.ts @@ -2,7 +2,7 @@ * Incident Components - Phase 7 + 6.5a */ -export { IncidentCard, IncidentCardGrid, IncidentEmptyState } from './incident-card' +export { IncidentCard } from './incident-card' export { DualStateIncidentCard, type DualStateIncidentCardProps,