Files
awoooi/apps/web/src/stores/approval.store.ts
Your Name 6ccdf199ad
All checks were successful
CD Pipeline / tests (push) Successful in 1m23s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 4m18s
CD Pipeline / post-deploy-checks (push) Successful in 1m44s
chore(web): 清理 IwoooS D2 註解語氣
2026-06-05 01:11:44 +08:00

729 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Approval Store - HITL 授權狀態管理
* ===================================
* CISO-101: Multi-Sig 信任鏈前端整合
*
* Features:
* - 輪詢 GET /api/v1/approvals/pending
* - 簽核 POST /api/v1/approvals/{id}/sign
* - 拒絕 POST /api/v1/approvals/{id}/reject
* - Multi-Sig UX 狀態管理
*/
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import type { ApprovalRequest as FrontendApprovalRequest } from '@/components/approval/approval-card'
// =============================================================================
// Types (與後端 Pydantic 模型對應)
// =============================================================================
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired'
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
export type DataImpact = 'none' | 'read_only' | 'write' | 'destructive'
export interface BlastRadius {
affected_pods: number
estimated_downtime: string
related_services: string[]
data_impact: DataImpact
}
export interface DryRunCheck {
name: string
passed: boolean
message?: string | null
}
export interface Signature {
id: string
signer_id: string
signer_name: string
signed_at: string
comment?: string | null
}
export interface ApprovalRequest {
id: string
action: string
description: string
status: ApprovalStatus
risk_level: RiskLevel
blast_radius: BlastRadius
dry_run_checks: DryRunCheck[]
required_signatures: number
current_signatures: number
signatures: Signature[]
requested_by: string
created_at: string
expires_at: string | null
resolved_at: string | null
// 戰略 B: 告警風暴收斂
fingerprint?: string | null
hit_count?: number
last_seen_at?: string | null
incident_id?: string | null
matched_playbook_id?: string | null
telegram_message_id?: number | null
telegram_chat_id?: number | null
metadata?: Record<string, unknown> | null
}
export interface SignResponse {
success: boolean
message: string
approval: ApprovalRequest
execution_triggered: boolean
}
// =============================================================================
// Store State
// =============================================================================
type SSEConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
interface ApprovalState {
// Data
pendingApprovals: ApprovalRequest[]
isLoading: boolean
error: string | null
lastFetched: Date | null
// Signing state
signingId: string | null
rejectingId: string | null
// Recently resolved (for animation)
recentlyApproved: Set<string>
recentlyRejected: Set<string>
// SSE Connection (Phase 15: Polling → SSE)
sseStatus: SSEConnectionStatus
sseReconnectAttempts: number
// Polling (Legacy, kept for fallback)
pollingInterval: number | null
// Actions
fetchPending: () => Promise<void>
// Phase 20: CSRF Token 參數 (可選,向後相容)
signApproval: (id: string, signerId: string, signerName: string, comment?: string, csrfToken?: string) => Promise<SignResponse | null>
rejectApproval: (id: string, rejectorId: string, rejectorName: string, reason: string, csrfToken?: string) => Promise<boolean>
// SSE Actions (Phase 15)
connectSSE: () => void
disconnectSSE: () => void
// Polling (Legacy)
startPolling: (intervalMs?: number) => void
stopPolling: () => void
clearRecentlyResolved: (id: string) => void
setError: (error: string | null) => void
}
// =============================================================================
// Constants
// =============================================================================
const DEFAULT_POLLING_INTERVAL = 5000 // 5 seconds
const MAX_SSE_RECONNECT_ATTEMPTS = 10
const BASE_RECONNECT_DELAY = 1000 // 1 second
const MAX_RECONNECT_DELAY = 30000 // 30 seconds
// 專案鐵律: 禁止任何 Fallback IP
const getApiBaseUrl = (): string => {
if (typeof window === 'undefined') return ''
const url = process.env.NEXT_PUBLIC_API_URL
if (!url) {
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
return ''
}
return url
}
const API_BASE_URL = getApiBaseUrl()
// =============================================================================
// Store Implementation
// =============================================================================
let pollingTimer: NodeJS.Timeout | null = null
let eventSource: EventSource | null = null
let sseReconnectTimeout: NodeJS.Timeout | null = null
export const useApprovalStore = create<ApprovalState>()(
subscribeWithSelector((set, get) => ({
// Initial state
pendingApprovals: [],
isLoading: false,
error: null,
lastFetched: null,
signingId: null,
rejectingId: null,
recentlyApproved: new Set(),
recentlyRejected: new Set(),
// SSE state (Phase 15)
sseStatus: 'disconnected',
sseReconnectAttempts: 0,
// Polling (Legacy)
pollingInterval: null,
// ==========================================================================
// Fetch Pending Approvals
// ==========================================================================
fetchPending: async () => {
set({ isLoading: true, error: null })
try {
const response = await fetch(`${API_BASE_URL}/api/v1/approvals/pending`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
const approvals: ApprovalRequest[] = data.approvals || []
console.log('[Approval] Fetched pending:', approvals.length)
set({
pendingApprovals: approvals,
isLoading: false,
lastFetched: new Date(),
})
} catch (err) {
console.error('[Approval] Fetch failed:', err)
set({
isLoading: false,
error: `Failed to fetch approvals: ${err}`,
})
}
},
// ==========================================================================
// Sign Approval (Phase 15: Optimistic Updates)
// ==========================================================================
signApproval: async (id, signerId, signerName, comment, csrfToken) => {
// 🔧 Race Condition 修復: 簽核期間暫停 Polling
const wasPolling = pollingTimer !== null
if (wasPolling) {
clearInterval(pollingTimer!)
pollingTimer = null
console.log('[Approval] Polling paused during sign')
}
// 🎯 Phase 15: 樂觀更新 - 立即更新 UI (Optimistic Update)
const state = get()
const originalApprovals = [...state.pendingApprovals]
const targetApproval = originalApprovals.find((a) => a.id === id)
if (targetApproval) {
// 樂觀更新: 假設簽核成功,立即增加簽章數
const optimisticApproval = {
...targetApproval,
current_signatures: targetApproval.current_signatures + 1,
// 如果達到要求簽章數,預設為 approved
status: (targetApproval.current_signatures + 1 >= targetApproval.required_signatures
? 'approved' : targetApproval.status) as ApprovalStatus,
}
set({
pendingApprovals: state.pendingApprovals.map((a) =>
a.id === id ? optimisticApproval : a
),
signingId: id,
error: null,
})
console.log('[Approval] Optimistic update applied:', id, {
signatures: `${optimisticApproval.current_signatures}/${optimisticApproval.required_signatures}`,
status: optimisticApproval.status,
})
} else {
set({ signingId: id, error: null })
}
try {
// Phase 20: CSRF Protection
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken
}
const response = await fetch(`${API_BASE_URL}/api/v1/approvals/${id}/sign`, {
method: 'POST',
headers,
credentials: 'include', // Phase 20: 確保 cookie 被發送
body: JSON.stringify({
signer_id: signerId,
signer_name: signerName,
comment: comment || null,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `HTTP ${response.status}`)
}
const result: SignResponse = await response.json()
console.log('[Approval] Signed:', id, result.message)
// Update local state
// 🔴 2026-03-26 修復: 簽核後保留內容顯示,不立即移除
set((state) => {
const updatedApprovals = state.pendingApprovals.map((a) =>
a.id === id ? result.approval : a
)
// If approved, mark for removal animation (但不立即移除)
const newRecentlyApproved = new Set(state.recentlyApproved)
if (result.approval.status === 'approved') {
newRecentlyApproved.add(id)
}
return {
// 🔴 修復: 保留在列表中,讓 UI 顯示已簽核狀態
pendingApprovals: updatedApprovals,
signingId: null,
recentlyApproved: newRecentlyApproved,
}
})
// 🔴 延遲 5 秒後才從列表移除 (讓用戶看到完整簽核結果)
if (result.approval.status === 'approved') {
setTimeout(() => {
set((state) => ({
pendingApprovals: state.pendingApprovals.filter((a) => a.id !== id),
}))
console.log('[Approval] Removed approved item after delay:', id)
}, 5000)
}
// 🔧 Race Condition 修復: 延遲 1 秒後恢復 Polling讓後端有時間更新
if (wasPolling) {
setTimeout(() => {
get().startPolling(get().pollingInterval || DEFAULT_POLLING_INTERVAL)
console.log('[Approval] Polling resumed after sign')
}, 1000)
}
return result
} catch (err) {
console.error('[Approval] Sign failed:', err)
// 🎯 Phase 15: 樂觀更新失敗 - 回滾到原始狀態 (Rollback)
set({
pendingApprovals: originalApprovals,
signingId: null,
error: `Sign failed: ${err}`,
})
console.log('[Approval] Rollback applied due to error')
// 🔧 失敗也要恢復 Polling
if (wasPolling) {
get().startPolling(get().pollingInterval || DEFAULT_POLLING_INTERVAL)
console.log('[Approval] Polling resumed after sign error')
}
return null
}
},
// ==========================================================================
// Reject Approval (Phase 15: Optimistic Updates)
// ==========================================================================
rejectApproval: async (id, rejectorId, rejectorName, reason, csrfToken) => {
// 🔧 Race Condition 修復: 拒絕期間暫停 Polling
const wasPolling = pollingTimer !== null
if (wasPolling) {
clearInterval(pollingTimer!)
pollingTimer = null
console.log('[Approval] Polling paused during reject')
}
// 🎯 Phase 15: 樂觀更新 - 立即更新 UI (Optimistic Update)
const state = get()
const originalApprovals = [...state.pendingApprovals]
// 樂觀更新: 假設拒絕成功,立即更新狀態
set({
pendingApprovals: state.pendingApprovals.map((a) =>
a.id === id ? { ...a, status: 'rejected' as ApprovalStatus } : a
),
rejectingId: id,
error: null,
})
console.log('[Approval] Optimistic reject applied:', id)
try {
// Phase 20: CSRF Protection
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken
}
const response = await fetch(`${API_BASE_URL}/api/v1/approvals/${id}/reject`, {
method: 'POST',
headers,
credentials: 'include', // Phase 20: 確保 cookie 被發送
body: JSON.stringify({
rejector_id: rejectorId,
rejector_name: rejectorName,
reason,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `HTTP ${response.status}`)
}
console.log('[Approval] Rejected:', id)
// Update local state
// 🔴 2026-03-26 修復: 拒絕後保留內容顯示,不立即移除
set((state) => {
const newRecentlyRejected = new Set(state.recentlyRejected)
newRecentlyRejected.add(id)
// 更新狀態但保留在列表中
const updatedApprovals = state.pendingApprovals.map((a) =>
a.id === id ? { ...a, status: 'rejected' as const } : a
)
return {
pendingApprovals: updatedApprovals,
rejectingId: null,
recentlyRejected: newRecentlyRejected,
}
})
// 🔴 延遲 5 秒後才從列表移除
setTimeout(() => {
set((state) => ({
pendingApprovals: state.pendingApprovals.filter((a) => a.id !== id),
}))
console.log('[Approval] Removed rejected item after delay:', id)
}, 5000)
// 🔧 Race Condition 修復: 延遲 1 秒後恢復 Polling
if (wasPolling) {
setTimeout(() => {
get().startPolling(get().pollingInterval || DEFAULT_POLLING_INTERVAL)
console.log('[Approval] Polling resumed after reject')
}, 1000)
}
return true
} catch (err) {
console.error('[Approval] Reject failed:', err)
// 🎯 Phase 15: 樂觀更新失敗 - 回滾到原始狀態 (Rollback)
set({
pendingApprovals: originalApprovals,
rejectingId: null,
error: `Reject failed: ${err}`,
})
console.log('[Approval] Rollback applied due to reject error')
// 🔧 失敗也要恢復 Polling
if (wasPolling) {
get().startPolling(get().pollingInterval || DEFAULT_POLLING_INTERVAL)
console.log('[Approval] Polling resumed after reject error')
}
return false
}
},
// ==========================================================================
// SSE Connection (Phase 15: Polling → SSE)
// ==========================================================================
connectSSE: () => {
const state = get()
// Already connected or connecting
if (eventSource && state.sseStatus === 'connected') {
console.log('[Approval SSE] Already connected')
return
}
// Clean up existing connection
if (eventSource) {
eventSource.close()
eventSource = null
}
if (!API_BASE_URL) {
console.error('[Approval SSE] Missing API URL')
set({ sseStatus: 'error', error: 'Missing API URL' })
return
}
set({ sseStatus: 'connecting', error: null })
console.log('[Approval SSE] Connecting to', `${API_BASE_URL}/api/v1/approvals/stream`)
// Initial fetch
state.fetchPending()
// Create EventSource
eventSource = new EventSource(`${API_BASE_URL}/api/v1/approvals/stream`)
eventSource.onopen = () => {
console.log('[Approval SSE] Connected')
set({
sseStatus: 'connected',
sseReconnectAttempts: 0,
error: null,
})
}
// Handle approval events (Phase 15: Incremental Updates)
eventSource.addEventListener('approval', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as {
action: 'created' | 'signed' | 'rejected' | 'expired' | 'executed'
approval_id: string
approval?: ApprovalRequest
}
console.log('[Approval SSE] Event:', data.action, data.approval_id)
// 🎯 Phase 15: 增量更新取代全量 re-fetch
switch (data.action) {
case 'created':
// 新增: 如果有完整資料,直接加入列表
if (data.approval) {
set((state) => ({
pendingApprovals: [data.approval!, ...state.pendingApprovals],
}))
} else {
// 無完整資料時才 re-fetch
get().fetchPending()
}
break
case 'signed':
// 簽核: 更新或確認樂觀更新
if (data.approval) {
set((state) => ({
pendingApprovals: state.pendingApprovals.map((a) =>
a.id === data.approval_id ? data.approval! : a
),
}))
}
break
case 'rejected':
case 'expired':
case 'executed':
// 移除: 直接從列表移除 (延遲 2 秒讓 UI 顯示最終狀態)
setTimeout(() => {
set((state) => ({
pendingApprovals: state.pendingApprovals.filter(
(a) => a.id !== data.approval_id
),
}))
}, 2000)
break
default:
// 未知事件類型: re-fetch 作為 fallback
get().fetchPending()
}
} catch (err) {
console.error('[Approval SSE] Failed to parse event:', err)
}
})
eventSource.addEventListener('heartbeat', () => {
console.log('[Approval SSE] Heartbeat')
})
eventSource.addEventListener('connected', () => {
console.log('[Approval SSE] Server confirmed connection')
})
// Error handling with exponential backoff
eventSource.onerror = () => {
const attempts = get().sseReconnectAttempts
if (attempts >= MAX_SSE_RECONNECT_ATTEMPTS) {
console.error('[Approval SSE] Max reconnect attempts reached, falling back to polling')
set({ sseStatus: 'error', error: 'SSE connection failed' })
eventSource?.close()
eventSource = null
// Fallback to polling
get().startPolling()
return
}
set({
sseStatus: 'reconnecting',
sseReconnectAttempts: attempts + 1,
})
const delay = Math.min(
BASE_RECONNECT_DELAY * Math.pow(2, attempts),
MAX_RECONNECT_DELAY
)
console.log(`[Approval SSE] Reconnecting in ${delay}ms (attempt ${attempts + 1})`)
if (sseReconnectTimeout) clearTimeout(sseReconnectTimeout)
sseReconnectTimeout = setTimeout(() => {
eventSource?.close()
eventSource = null
get().connectSSE()
}, delay)
}
},
disconnectSSE: () => {
console.log('[Approval SSE] Disconnecting')
if (eventSource) {
eventSource.close()
eventSource = null
}
if (sseReconnectTimeout) {
clearTimeout(sseReconnectTimeout)
sseReconnectTimeout = null
}
set({ sseStatus: 'disconnected', sseReconnectAttempts: 0 })
},
// ==========================================================================
// Polling (Legacy Fallback)
// ==========================================================================
startPolling: (intervalMs = DEFAULT_POLLING_INTERVAL) => {
const state = get()
// Clear existing timer
if (pollingTimer) {
clearInterval(pollingTimer)
}
// Initial fetch
state.fetchPending()
// Start polling
pollingTimer = setInterval(() => {
get().fetchPending()
}, intervalMs)
set({ pollingInterval: intervalMs })
console.log('[Approval] Polling started:', intervalMs, 'ms')
},
stopPolling: () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
set({ pollingInterval: null })
console.log('[Approval] Polling stopped')
},
clearRecentlyResolved: (id) => {
set((state) => {
const newApproved = new Set(state.recentlyApproved)
const newRejected = new Set(state.recentlyRejected)
newApproved.delete(id)
newRejected.delete(id)
return {
recentlyApproved: newApproved,
recentlyRejected: newRejected,
}
})
},
setError: (error) => {
set({ error })
},
}))
)
// =============================================================================
// Selector Hooks
// =============================================================================
export const usePendingApprovals = () =>
useApprovalStore((state) => state.pendingApprovals)
export const useApprovalCount = () =>
useApprovalStore((state) => state.pendingApprovals.length)
export const useApprovalById = (id: string) =>
useApprovalStore((state) => state.pendingApprovals.find((a) => a.id === id))
export const useIsSigningApproval = (id: string) =>
useApprovalStore((state) => state.signingId === id)
export const useIsRejectingApproval = (id: string) =>
useApprovalStore((state) => state.rejectingId === id)
export const useApprovalError = () =>
useApprovalStore((state) => state.error)
// SSE Status Hooks (Phase 15)
export const useApprovalSSEStatus = () =>
useApprovalStore((state) => state.sseStatus)
export const useApprovalSSEConnected = () =>
useApprovalStore((state) => state.sseStatus === 'connected')
// =============================================================================
// Type Converters (Backend → Frontend)
// =============================================================================
// P1 修復: 使用 mapping object 取代 toUpperCase() as Type (2026-03-26)
const DATA_IMPACT_MAP: Record<string, 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE'> = {
'none': 'NONE',
'read_only': 'READ_ONLY',
'read-only': 'READ_ONLY',
'readonly': 'READ_ONLY',
'write': 'WRITE',
'destructive': 'DESTRUCTIVE',
// 大寫版本 (防禦性)
'NONE': 'NONE',
'READ_ONLY': 'READ_ONLY',
'WRITE': 'WRITE',
'DESTRUCTIVE': 'DESTRUCTIVE',
}
export function toFrontendApproval(backend: ApprovalRequest): FrontendApprovalRequest {
const rawDataImpact = backend.blast_radius.data_impact || 'none'
const dataImpact = DATA_IMPACT_MAP[rawDataImpact] || DATA_IMPACT_MAP[rawDataImpact.toLowerCase()] || 'NONE'
return {
id: backend.id,
action: backend.action,
description: backend.description,
riskLevel: backend.risk_level === 'critical' ? 'critical' :
backend.risk_level === 'high' ? 'high' :
backend.risk_level === 'medium' ? 'medium' : 'low',
blastRadius: {
affectedPods: backend.blast_radius.affected_pods,
estimatedDowntime: backend.blast_radius.estimated_downtime,
relatedServices: backend.blast_radius.related_services,
dataImpact,
},
dryRunChecks: backend.dry_run_checks.map((c) => ({
name: c.name,
passed: c.passed,
message: c.message || undefined,
})),
requiredSignatures: backend.required_signatures,
currentSignatures: backend.current_signatures,
requestedBy: backend.requested_by,
requestedAt: new Date(backend.created_at).toLocaleString('zh-TW'),
// 戰略 B: 告警風暴收斂
hitCount: backend.hit_count ?? 1,
lastSeenAt: backend.last_seen_at || undefined,
fingerprint: backend.fingerprint || undefined,
}
}