729 lines
23 KiB
TypeScript
729 lines
23 KiB
TypeScript
/**
|
||
* 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,
|
||
}
|
||
}
|