'use client' /** * AutoRepairPanel — 自動修復面板 (不含 AppLayout) * ================================================== * Sprint 5: 從 /auto-repair/page.tsx 抽取 * 供原始頁面和整合頁面 (/automation) 共用 * * 建立時間: 2026-04-09 (台北時區) */ import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslations } from 'next-intl' 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 DispositionSummary { auto_repair: number human_approved: number manual_resolved: number cold_start_trust: number total: number auto_rate: number } interface AutoRepairStats { approved_playbooks: number high_quality_playbooks: number total_executions: number overall_success_rate: number auto_repair_eligible: boolean disposition_summary?: DispositionSummary } 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', } // ============================================================================= // StatCard // ============================================================================= function StatCard({ label, value, sub, highlight, }: { label: string; value: string | number; sub?: string; highlight?: boolean }) { return (

{label}

{value}

{sub &&

{sub}

}
) } // ============================================================================= // IncidentEvalRow // ============================================================================= function IncidentEvalRow({ incidentId, severity, }: { incidentId: string; severity: string }) { const t = useTranslations('autoRepair') 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()) } } catch { // API 不可用時靜默處理 } finally { setLoading(false) } }, [incidentId]) useEffect(() => { fetchEval() }, [fetchEval]) const handleExecute = async () => { if (!eval_?.playbook_id) return setExecuting(true) try { const base = getApiBaseUrl() // Phase 20: CSRF Protection — 先取得 token 再執行 const csrfRes = await fetch(`${base}/api/v1/csrf/token`, { method: 'GET', credentials: 'include' }) if (!csrfRes.ok) throw new Error(`CSRF fetch failed: ${csrfRes.status}`) const csrfData = await csrfRes.json() const csrfHeaders: Record = csrfData.token ? { 'X-CSRF-Token': String(csrfData.token) } : {} const res = await fetch(`${base}/api/v1/auto-repair/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders }, body: JSON.stringify({ incident_id: incidentId, playbook_id: eval_.playbook_id }), }) if (res.ok) setResult(await res.json()) } finally { setExecuting(false) } } return (
setExpanded(v => !v)} > {severity} {incidentId} {loading && } {!loading && eval_ && ( eval_.can_auto_repair ? {t('canAutoRepair')} : {t('notEligibleShort')} )} {expanded ? : }
{expanded && eval_ && (
Playbook

{eval_.playbook_name ?? '—'}

{t('riskLevel')}

{eval_.risk_level}

{eval_.success_rate != null && (
{t('successRate')}

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

)} {eval_.total_executions != null && (
{t('execCount')}

{eval_.total_executions}

)}
{t('decisionReason')}

{eval_.reason}

{result && (
{result.success ? : } {result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error ?? '' })}
{result.executed_steps.length > 0 && (
    {result.executed_steps.map((step, i) => (
  • {step}
  • ))}
)}
)} {eval_.can_auto_repair && !result && ( )}
)}
) } // ============================================================================= // AutoRepairPanel // ============================================================================= export function AutoRepairPanel() { const t = useTranslations('autoRepair') const tNav = useTranslations('nav') const tCommon = useTranslations('common') const [stats, setStats] = useState(null) const [statsLoading, setStatsLoading] = useState(true) const [statsError, setStatsError] = useState(null) const [disposition, setDisposition] = useState<{ total: number; auto_repair: number; human_approved: number; manual_resolved: number; cold_start_trust: number; auto_rate: number } | null>(null) const abortRef = useRef(null) const { incidents, isLoading: incidentsLoading } = useIncidents({ pollInterval: 30000, enablePolling: true, }) 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, dispRes] = await Promise.all([ fetch(`${base}/api/v1/auto-repair/stats`, { signal: ctrl.signal }), fetch(`${base}/api/v1/stats/disposition`, { signal: ctrl.signal }).catch(() => null), ]) if (!res.ok) throw new Error(`HTTP ${res.status}`) setStats(await res.json()) if (dispRes?.ok) { const d = await dispRes.json() setDisposition(d.summary ?? null) } } 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 */}

{tNav('autoRepair')}

{t('subtitle')}

{/* Error */} {statsError && (
{statsError}
)} {/* Stats */} {stats && (
)} {/* Disposition summary */} {disposition && disposition.total > 0 && (

{t('dispositionAuto')}

{disposition.auto_repair}

{t('dispositionHuman')}

{disposition.human_approved}

{t('dispositionManual')}

{disposition.manual_resolved}

{t('dispositionCold')}

{disposition.cold_start_trust}

)} {/* Eligible indicator */} {stats && (
{stats.auto_repair_eligible ? : }

{stats.auto_repair_eligible ? t('ready') : t('notReady')}

{stats.auto_repair_eligible ? t('readyDesc', { count: stats.high_quality_playbooks }) : t('notReadyDesc')}

)} {/* Incident evaluation list */}

{t('incidentEval')}

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

{t('noEligible')}

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