新增功能: - errors/: Sentry 錯誤儀表板 (overview-card, trend-chart, issues-list) - genui/ApprovalCard: GenUI 風格簽核卡片 - terminal/OmniTerminal: AI 終端機組件 - useErrors.ts: 錯誤數據 hooks - terminal.store.ts: 終端機狀態管理 更新: - conversational-view.tsx: 改進對話式 UI - providers.tsx: 新增 provider - sidebar.tsx: 新增 Errors 導航 - api-client.ts: 錯誤 API 整合 - i18n messages: 新增錯誤相關翻譯 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
386 lines
10 KiB
TypeScript
386 lines
10 KiB
TypeScript
/**
|
||
* AWOOOI API Client
|
||
* ADR-005: 所有請求經過 BFF
|
||
*
|
||
* 統帥鐵律: 禁止任何 Fallback IP,環境變數缺失即噴錯
|
||
*/
|
||
|
||
// 絕對純化: 環境變數缺失時直接拋出致命錯誤,嚴禁任何 Fallback
|
||
const getApiBaseUrl = (): string => {
|
||
const url = process.env.NEXT_PUBLIC_API_URL
|
||
if (!url) {
|
||
const fatalMsg = '[AWOOOI FATAL] Missing NEXT_PUBLIC_API_URL configuration.'
|
||
console.error(fatalMsg)
|
||
if (typeof window !== 'undefined') {
|
||
console.error('%c' + fatalMsg, 'color: #ef4444; font-weight: bold; font-size: 16px;')
|
||
}
|
||
throw new Error(fatalMsg)
|
||
}
|
||
return url.endsWith('/api/v1') ? url : `${url}/api/v1`
|
||
}
|
||
|
||
const API_BASE_URL = getApiBaseUrl()
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
public status: number,
|
||
public code: string,
|
||
message: string
|
||
) {
|
||
super(message)
|
||
this.name = 'ApiError'
|
||
}
|
||
}
|
||
|
||
async function handleResponse<T>(response: Response): Promise<T> {
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({}))
|
||
throw new ApiError(
|
||
response.status,
|
||
error.code || 'UNKNOWN_ERROR',
|
||
error.message || response.statusText
|
||
)
|
||
}
|
||
return response.json()
|
||
}
|
||
|
||
export const apiClient = {
|
||
// Health
|
||
async getHealth() {
|
||
const res = await fetch(`${API_BASE_URL}/health`)
|
||
return handleResponse<{
|
||
status: 'healthy' | 'degraded' | 'unhealthy'
|
||
version: string
|
||
timestamp: string
|
||
components: Record<string, 'up' | 'down'>
|
||
}>(res)
|
||
},
|
||
|
||
// Agent
|
||
async getAgentStatus() {
|
||
const res = await fetch(`${API_BASE_URL}/agent/status`)
|
||
return handleResponse<{
|
||
status: 'idle' | 'thinking' | 'executing' | 'waiting_approval'
|
||
active_conversations: number
|
||
current_task: string | null
|
||
last_activity: string | null
|
||
}>(res)
|
||
},
|
||
|
||
async chat(message: string, conversationId?: string) {
|
||
const res = await fetch(`${API_BASE_URL}/agent/chat`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ message, conversation_id: conversationId }),
|
||
})
|
||
return handleResponse<{
|
||
message: string
|
||
conversation_id: string
|
||
requires_approval: boolean
|
||
approval_id?: string
|
||
}>(res)
|
||
},
|
||
|
||
// Plugins
|
||
async listPlugins(category?: string) {
|
||
const params = category ? `?category=${category}` : ''
|
||
const res = await fetch(`${API_BASE_URL}/plugins${params}`)
|
||
return handleResponse<Array<{
|
||
id: string
|
||
name: string
|
||
version: string
|
||
category: string
|
||
enabled: boolean
|
||
description?: string
|
||
}>>(res)
|
||
},
|
||
|
||
// Approvals
|
||
async listApprovals(status?: string) {
|
||
const params = status ? `?status=${status}` : ''
|
||
const res = await fetch(`${API_BASE_URL}/approvals${params}`)
|
||
return handleResponse<{
|
||
items: Array<{
|
||
id: string
|
||
type: string
|
||
status: string
|
||
action: {
|
||
plugin_id: string
|
||
operation: string
|
||
risk_level: string
|
||
}
|
||
requested_at: string
|
||
}>
|
||
}>(res)
|
||
},
|
||
|
||
async signApproval(approvalId: string, signer: string = 'commander', comment?: string) {
|
||
const res = await fetch(`${API_BASE_URL}/approvals/${approvalId}/sign`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
signer_id: signer,
|
||
signer_name: signer,
|
||
comment: comment,
|
||
}),
|
||
})
|
||
// 🔧 Fix: 回傳型別與後端實際結構對齊
|
||
return handleResponse<{
|
||
success: boolean
|
||
message: string
|
||
approval: ApprovalResponse
|
||
execution_triggered: boolean
|
||
// 向下相容舊欄位 (deprecated)
|
||
approval_id?: string
|
||
status?: string
|
||
current_signatures?: number
|
||
required_signatures?: number
|
||
}>(res)
|
||
},
|
||
|
||
async rejectApproval(approvalId: string, reason?: string) {
|
||
const res = await fetch(`${API_BASE_URL}/approvals/${approvalId}/reject`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
rejector_id: 'commander',
|
||
rejector_name: 'Commander',
|
||
reason: reason || 'Rejected via WarRoom',
|
||
}),
|
||
})
|
||
return handleResponse<{ id: string; status: string }>(res)
|
||
},
|
||
|
||
// =========================================================================
|
||
// Phase 7: Incidents API (真實血脈)
|
||
// =========================================================================
|
||
|
||
async listIncidents() {
|
||
const res = await fetch(`${API_BASE_URL}/incidents`)
|
||
return handleResponse<IncidentListResponse>(res)
|
||
},
|
||
|
||
async getIncident(incidentId: string) {
|
||
const res = await fetch(`${API_BASE_URL}/incidents/${incidentId}`)
|
||
return handleResponse<IncidentResponse>(res)
|
||
},
|
||
|
||
async generateProposal(incidentId: string) {
|
||
const res = await fetch(`${API_BASE_URL}/incidents/${incidentId}/proposal`, {
|
||
method: 'POST',
|
||
})
|
||
return handleResponse<ProposalGenerateResponse>(res)
|
||
},
|
||
|
||
// =========================================================================
|
||
// Phase 7: Pending Approvals API (真實血脈)
|
||
// =========================================================================
|
||
|
||
async getPendingApprovals() {
|
||
const res = await fetch(`${API_BASE_URL}/approvals/pending`)
|
||
return handleResponse<PendingApprovalsResponse>(res)
|
||
},
|
||
|
||
// =========================================================================
|
||
// Phase 10: Sentry Errors API (#40 BFF)
|
||
// =========================================================================
|
||
|
||
async getErrorStats() {
|
||
const res = await fetch(`${API_BASE_URL}/errors/stats`)
|
||
return handleResponse<ErrorStatsResponse>(res)
|
||
},
|
||
|
||
async listErrors(params?: { status?: string; level?: string; limit?: number }) {
|
||
const searchParams = new URLSearchParams()
|
||
if (params?.status) searchParams.set('status', params.status)
|
||
if (params?.level) searchParams.set('level', params.level)
|
||
if (params?.limit) searchParams.set('limit', params.limit.toString())
|
||
const query = searchParams.toString() ? `?${searchParams.toString()}` : ''
|
||
const res = await fetch(`${API_BASE_URL}/errors/issues${query}`)
|
||
return handleResponse<ErrorListResponse>(res)
|
||
},
|
||
|
||
async getErrorDetail(issueId: string) {
|
||
const res = await fetch(`${API_BASE_URL}/errors/issues/${issueId}`)
|
||
return handleResponse<ErrorDetailResponse>(res)
|
||
},
|
||
|
||
async getErrorTrends(period: '24h' | '7d' | '30d' = '24h') {
|
||
const res = await fetch(`${API_BASE_URL}/errors/trends?period=${period}`)
|
||
return handleResponse<ErrorTrendResponse>(res)
|
||
},
|
||
|
||
async analyzeError(issueId: string) {
|
||
const res = await fetch(`${API_BASE_URL}/errors/issues/${issueId}/analyze`, {
|
||
method: 'POST',
|
||
})
|
||
return handleResponse<ErrorAnalysisResponse>(res)
|
||
},
|
||
}
|
||
|
||
// =========================================================================
|
||
// Type Definitions (Phase 7)
|
||
// =========================================================================
|
||
|
||
/**
|
||
* Phase 6.5: 決策令牌資訊
|
||
* 確保 UI 永遠有決策可操作
|
||
*/
|
||
export interface DecisionInfo {
|
||
token: string
|
||
state: 'init' | 'analyzing' | 'ready' | 'executing' | 'completed' | 'error'
|
||
proposal_data: {
|
||
action: string
|
||
description: string
|
||
reasoning: string
|
||
risk_level: 'low' | 'medium' | 'critical'
|
||
kubectl_command: string
|
||
source: string
|
||
confidence: number
|
||
} | null
|
||
proposal_id: string | null
|
||
}
|
||
|
||
export interface IncidentResponse {
|
||
incident_id: string
|
||
status: 'investigating' | 'mitigating' | 'resolved' | 'closed'
|
||
severity: 'P0' | 'P1' | 'P2' | 'P3'
|
||
signal_count: number
|
||
affected_services: string[]
|
||
proposal_count: number
|
||
created_at: string
|
||
updated_at: string
|
||
/** Phase 6.5: 決策令牌 (確保 UI 永不鎖死) */
|
||
decision: DecisionInfo | null
|
||
}
|
||
|
||
export interface IncidentListResponse {
|
||
count: number
|
||
incidents: IncidentResponse[]
|
||
}
|
||
|
||
export interface BlastRadius {
|
||
affected_pods: number
|
||
estimated_downtime: string
|
||
related_services: string[]
|
||
data_impact: 'none' | 'read_only' | 'write' | 'destructive'
|
||
}
|
||
|
||
export interface DryRunCheck {
|
||
name: string
|
||
passed: boolean
|
||
message: string
|
||
}
|
||
|
||
export interface ApprovalResponse {
|
||
id: string
|
||
action: string
|
||
description: string
|
||
status: 'pending' | 'approved' | 'rejected' | 'expired'
|
||
risk_level: 'low' | 'medium' | 'high' | 'critical'
|
||
blast_radius: BlastRadius
|
||
dry_run_checks: DryRunCheck[]
|
||
required_signatures: number
|
||
current_signatures: number
|
||
signatures: Array<{ signer: string; signed_at: string }>
|
||
requested_by: string
|
||
created_at: string
|
||
expires_at: string | null
|
||
}
|
||
|
||
export interface PendingApprovalsResponse {
|
||
count: number
|
||
approvals: ApprovalResponse[]
|
||
}
|
||
|
||
export interface ProposalGenerateResponse {
|
||
success: boolean
|
||
message: string
|
||
incident_id: string
|
||
proposal: ApprovalResponse | null
|
||
incident_status: string | null
|
||
}
|
||
|
||
// =========================================================================
|
||
// Phase 10: Sentry Error Types (#40 BFF)
|
||
// =========================================================================
|
||
|
||
export interface SentryIssue {
|
||
id: string
|
||
short_id: string
|
||
title: string
|
||
culprit: string | null
|
||
level: 'error' | 'warning' | 'info' | 'fatal'
|
||
status: 'unresolved' | 'resolved' | 'ignored'
|
||
count: number
|
||
user_count: number
|
||
first_seen: string
|
||
last_seen: string
|
||
permalink: string | null
|
||
}
|
||
|
||
export interface ErrorStatsResponse {
|
||
total_issues: number
|
||
unresolved_issues: number
|
||
error_count_24h: number
|
||
critical_count: number
|
||
projects: string[]
|
||
}
|
||
|
||
export interface ErrorListResponse {
|
||
issues: SentryIssue[]
|
||
total: number
|
||
has_more: boolean
|
||
}
|
||
|
||
export interface ErrorDetailResponse {
|
||
issue: Record<string, unknown>
|
||
latest_event: Record<string, unknown> | null
|
||
sentry_url: string
|
||
}
|
||
|
||
export interface ErrorTrendPoint {
|
||
timestamp: string
|
||
count: number
|
||
}
|
||
|
||
export interface ErrorTrendResponse {
|
||
period: '24h' | '7d' | '30d'
|
||
data: ErrorTrendPoint[]
|
||
total_count: number
|
||
change_percent: number
|
||
}
|
||
|
||
export interface FixRecommendation {
|
||
summary: string
|
||
steps: string[]
|
||
code_suggestion: string | null
|
||
}
|
||
|
||
export interface PreventionMeasure {
|
||
type: string
|
||
description: string
|
||
}
|
||
|
||
export interface ErrorAnalysis {
|
||
root_cause: string
|
||
category: string
|
||
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
||
impact_assessment: string
|
||
fix_recommendation: FixRecommendation
|
||
prevention: PreventionMeasure[]
|
||
related_files: string[]
|
||
confidence: number
|
||
reasoning: string
|
||
}
|
||
|
||
export interface ErrorAnalysisResponse {
|
||
status: 'completed' | 'failed'
|
||
issue_id: string
|
||
provider: string
|
||
analysis?: ErrorAnalysis
|
||
analyzed_at?: string
|
||
sentry_url: string
|
||
message?: string
|
||
}
|