From b51241826c641be7fd51168217d3ed2940213067 Mon Sep 17 00:00:00 2001 From: OG T Date: Wed, 1 Apr 2026 23:02:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(auto-repair):=20=E5=BE=9E=E4=BD=94?= =?UTF-8?q?=E4=BD=8D=E7=AC=A6=E5=8D=87=E7=B4=9A=E7=82=BA=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E4=BF=AE=E5=BE=A9=E9=A0=81=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 統計面板: Playbook 數/高品質數/執行次數/成功率 - 自動修復就緒狀態指示器 - 活躍 P1/P2 Incident 評估列表(可展開查看 Playbook 詳情) - 執行修復按鈕(呼叫 /api/v1/auto-repair/execute) Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/app/[locale]/auto-repair/page.tsx | 408 +++++++++++++++++- 1 file changed, 399 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/[locale]/auto-repair/page.tsx b/apps/web/src/app/[locale]/auto-repair/page.tsx index 159902588..32a6982cc 100644 --- a/apps/web/src/app/[locale]/auto-repair/page.tsx +++ b/apps/web/src/app/[locale]/auto-repair/page.tsx @@ -1,22 +1,412 @@ 'use client' /** - * 自動修復 Page - * @created 2026-04-01 ogt - 路由佔位 (awaiting implementation) + * Auto Repair Page - 自動修復 + * ============================ + * 顯示自動修復統計 + Incident 評估 + 執行紀錄 + * 資料來源: + * GET /api/v1/auto-repair/stats + * GET /api/v1/auto-repair/evaluate/{incident_id} + * GET /api/v1/incidents (活躍 incident 清單) + * + * @updated 2026-04-01 ogt - 從佔位符升級為完整頁面 */ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' -import { ComingSoon } from '@/components/layout' -import { Wrench } from 'lucide-react' +import { useIncidents } from '@/hooks/useIncidents' +import { cn } from '@/lib/utils' +import { + Wrench, RefreshCw, AlertCircle, + CheckCircle2, XCircle, ShieldAlert, + Play, ChevronDown, ChevronUp, Zap, +} from 'lucide-react' + +// ============================================================================= +// Types +// ============================================================================= + +interface AutoRepairStats { + approved_playbooks: number + high_quality_playbooks: number + total_executions: number + overall_success_rate: number + auto_repair_eligible: boolean +} + +interface EvaluateResponse { + can_auto_repair: boolean + playbook_id: string | null + playbook_name: string | null + reason: string + risk_level: string + blocked_by: string | null + success_rate: number | null + total_executions: number | null +} + +interface ExecuteResponse { + success: boolean + incident_id: string + playbook_id: string + executed_steps: string[] + error: string | null + execution_time_ms: number +} + +// ============================================================================= +// Helpers +// ============================================================================= + +const getApiBaseUrl = () => { + if (typeof window === 'undefined') return '' + const url = process.env.NEXT_PUBLIC_API_URL + if (!url) console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL') // eslint-disable-line no-console + return url ?? '' +} + +const RISK_STYLE: Record = { + LOW: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', + MEDIUM: 'bg-status-warning/10 text-status-warning border-status-warning/20', + HIGH: 'bg-status-critical/10 text-status-critical border-status-critical/20', + CRITICAL: 'bg-status-critical/10 text-status-critical border-status-critical/20', +} + +// ============================================================================= +// Sub-components +// ============================================================================= + +function StatCard({ + label, value, sub, highlight, +}: { label: string; value: string | number; sub?: string; highlight?: boolean }) { + return ( +
+

{label}

+

+ {value} +

+ {sub &&

{sub}

} +
+ ) +} + +// ============================================================================= +// Incident Evaluate Row +// ============================================================================= + +function IncidentEvalRow({ + incidentId, severity, +}: { incidentId: string; severity: string }) { + const [eval_, setEval] = useState(null) + const [loading, setLoading] = useState(false) + const [executing, setExecuting] = useState(false) + const [result, setResult] = useState(null) + const [expanded, setExpanded] = useState(false) + + const fetchEval = useCallback(async () => { + const base = getApiBaseUrl() + if (!base) return + setLoading(true) + try { + const res = await fetch(`${base}/api/v1/auto-repair/evaluate/${incidentId}`) + if (res.ok) setEval(await res.json()) + } finally { + setLoading(false) + } + }, [incidentId]) + + useEffect(() => { fetchEval() }, [fetchEval]) + + const handleExecute = async () => { + if (!eval_?.playbook_id) return + setExecuting(true) + try { + const base = getApiBaseUrl() + const res = await fetch(`${base}/api/v1/auto-repair/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ incident_id: incidentId, playbook_id: eval_.playbook_id }), + }) + if (res.ok) setResult(await res.json()) + } finally { + setExecuting(false) + } + } + + return ( +
+ {/* Header row */} +
setExpanded(v => !v)} + > + {severity} + {incidentId} + + {loading && } + {!loading && eval_ && ( + eval_.can_auto_repair + ? 可自動修復 + : 不符合條件 + )} + {expanded ? : } +
+ + {/* Expanded detail */} + {expanded && eval_ && ( +
+
+
+ Playbook +

{eval_.playbook_name ?? '—'}

+
+
+ 風險等級 +

+ + {eval_.risk_level} + +

+
+ {eval_.success_rate != null && ( +
+ 成功率 +

{(eval_.success_rate * 100).toFixed(1)}%

+
+ )} + {eval_.total_executions != null && ( +
+ 執行次數 +

{eval_.total_executions}

+
+ )} +
+ +
+ 決策原因 +

{eval_.reason}

+
+ + {/* Execute result */} + {result && ( +
+
+ {result.success + ? + : } + + {result.success ? `執行成功 (${result.execution_time_ms}ms)` : `執行失敗: ${result.error}`} + +
+ {result.executed_steps.length > 0 && ( +
    + {result.executed_steps.map((step, i) => ( +
  • + + {step} +
  • + ))} +
+ )} +
+ )} + + {/* Execute button */} + {eval_.can_auto_repair && !result && ( + + )} +
+ )} +
+ ) +} + +// ============================================================================= +// Page +// ============================================================================= export default function AutoRepairPage({ params }: { params: { locale: string } }) { + const t = useTranslations() + + const [stats, setStats] = useState(null) + const [statsLoading, setStatsLoading] = useState(true) + const [statsError, setStatsError] = useState(null) + const abortRef = useRef(null) + + const { incidents, isLoading: incidentsLoading } = useIncidents({ + pollInterval: 30000, + enablePolling: true, + }) + + // P0/P1/P2 只有這些才評估自動修復(嚴重度限制) + const eligibleIncidents = (incidents ?? []).filter(i => + i.severity === 'P1' || i.severity === 'P2' + ) + + const fetchStats = useCallback(async () => { + const base = getApiBaseUrl() + if (!base) return + abortRef.current?.abort() + const ctrl = new AbortController() + abortRef.current = ctrl + setStatsLoading(true) + setStatsError(null) + try { + const res = await fetch(`${base}/api/v1/auto-repair/stats`, { signal: ctrl.signal }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + setStats(await res.json()) + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') return + setStatsError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setStatsLoading(false) + } + }, []) + + useEffect(() => { + fetchStats() + return () => { abortRef.current?.abort() } + }, [fetchStats]) + return ( - + {/* Header */} +
+
+

+ + {t('nav.autoRepair')} +

+

+ 高品質 Playbook 自動執行 · 風險 ≤ MEDIUM · 成功率 ≥ 95% +

+
+ +
+ + {/* Error */} + {statsError && ( +
+ + {statsError} +
+ )} + + {/* Stats */} + {stats && ( +
+ + + + +
+ )} + + {/* Eligible indicator */} + {stats && ( +
+ {stats.auto_repair_eligible + ? + : } +
+

+ {stats.auto_repair_eligible ? '自動修復已就緒' : '自動修復未就緒'} +

+

+ {stats.auto_repair_eligible + ? `${stats.high_quality_playbooks} 個高品質 Playbook 可用` + : '需要至少 1 個高品質 Playbook(成功率 ≥ 95%、執行 ≥ 10 次)'} +

+
+
+ )} + + {/* Incident evaluation list */} +
+

+ 活躍 Incident 評估(P1/P2) +

+ + {incidentsLoading && ( +
+ +
+ )} + + {!incidentsLoading && eligibleIncidents.length === 0 && ( +
+ +

目前無符合自動修復條件的 Incident

+
+ )} + +
+ {eligibleIncidents.map(incident => ( + + ))} +
+
) }