Files
awoooi/apps/web/src/components/panels/AutoRepairPanel.tsx

477 lines
19 KiB
TypeScript

'use client'
/**
* AutoRepairPanel — 自動修復面板 (不含 AppLayout)
* ==================================================
* Sprint 5: 從 /auto-repair/page.tsx 抽取
* 供原始頁面和整合頁面 (/automation) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 更新時間: 2026-07-04 — UI/UX 全面升級 (Option A 明亮模式)
*/
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,
Activity, ArrowRight, TrendingUp, TrendingDown,
} 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<string, string> = {
LOW: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
MEDIUM: 'bg-[#fff7e8] text-[#8a5a08] border-[#d9b36f]',
HIGH: 'bg-[#fff7f3] text-[#d97757] border-[#f5cdb8]',
CRITICAL: 'bg-[#fff1f0] text-[#9f2f25] border-[#e2a29b]',
}
// =============================================================================
// IncidentEvalRow
// =============================================================================
function IncidentEvalRow({
incidentId, severity,
}: { incidentId: string; severity: string }) {
const t = useTranslations('autoRepair')
const [eval_, setEval] = useState<EvaluateResponse | null>(null)
const [loading, setLoading] = useState(false)
const [executing, setExecuting] = useState(false)
const [result, setResult] = useState<ExecuteResponse | null>(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()
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<string, string> = 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 (
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white transition-colors hover:border-[#d8d3c7]">
<div
className="flex cursor-pointer items-center gap-3 bg-[#faf9f3] px-4 py-3 transition-colors hover:bg-[#f5f4ed]"
onClick={() => setExpanded(v => !v)}
>
<span className={cn(
'rounded px-2 py-0.5 font-mono text-[11px] font-bold tracking-widest',
severity === 'P0' ? 'bg-[#fff1f0] text-[#9f2f25]' :
severity === 'P1' ? 'bg-[#fff7e8] text-[#8a5a08]' :
'bg-[#f5f4ed] text-[#5f5b52]'
)}>{severity}</span>
<span className="flex-1 truncate font-mono text-sm font-semibold text-[#141413]">{incidentId}</span>
{loading && <RefreshCw className="h-4 w-4 animate-spin text-[#d8d3c7]" />}
{!loading && eval_ && (
eval_.can_auto_repair
? <span className="flex items-center gap-1 font-mono text-[11px] font-bold text-[#17602a]"><CheckCircle2 className="h-3.5 w-3.5" />{t('canAutoRepair')}</span>
: <span className="flex items-center gap-1 font-mono text-[11px] font-bold text-[#77736a]"><XCircle className="h-3.5 w-3.5" />{t('notEligibleShort')}</span>
)}
{expanded ? <ChevronUp className="h-4 w-4 text-[#77736a]" /> : <ChevronDown className="h-4 w-4 text-[#77736a]" />}
</div>
{expanded && eval_ && (
<div className="space-y-4 px-4 py-4">
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">Playbook</span>
<p className="mt-1 truncate font-mono text-[#141413] font-semibold">{eval_.playbook_name ?? '—'}</p>
</div>
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('riskLevel')}</span>
<p className="mt-1">
<span className={cn('rounded border px-2 py-0.5 text-[11px] font-bold tracking-widest', RISK_STYLE[eval_.risk_level] ?? RISK_STYLE.MEDIUM)}>
{eval_.risk_level}
</span>
</p>
</div>
{eval_.success_rate != null && (
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('successRate')}</span>
<p className="mt-1 font-mono font-bold text-[#17602a]">{(eval_.success_rate * 100).toFixed(1)}%</p>
</div>
)}
{eval_.total_executions != null && (
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('execCount')}</span>
<p className="mt-1 font-mono font-bold text-[#141413]">{eval_.total_executions}</p>
</div>
)}
</div>
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-4">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('decisionReason')}</span>
<p className="mt-1.5 font-mono text-sm text-[#5f5b52] leading-relaxed">{eval_.reason}</p>
</div>
{result && (
<div className={cn(
'rounded-lg border p-4',
result.success ? 'bg-[#f0faf2] border-[#8fc29a]' : 'bg-[#fff1f0] border-[#e2a29b]'
)}>
<div className="mb-3 flex items-center gap-2">
{result.success
? <CheckCircle2 className="h-5 w-5 text-[#17602a]" />
: <XCircle className="h-5 w-5 text-[#9f2f25]" />}
<span className={cn('font-mono text-sm font-bold', result.success ? 'text-[#17602a]' : 'text-[#9f2f25]')}>
{result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error ?? '' })}
</span>
</div>
{result.executed_steps.length > 0 && (
<ul className="space-y-1.5">
{result.executed_steps.map((step, i) => (
<li key={i} className="flex items-start gap-2 font-mono text-xs text-[#5f5b52]">
<ArrowRight className="mt-0.5 h-3 w-3 shrink-0 text-[#4A90D9]" />
{step}
</li>
))}
</ul>
)}
</div>
)}
{eval_.can_auto_repair && !result && (
<div className="flex justify-end pt-2">
<button
onClick={handleExecute}
disabled={executing}
className={cn(
'inline-flex items-center gap-2 rounded-lg px-5 py-2.5',
'bg-[#4A90D9] font-mono text-sm font-bold text-white shadow-md',
'transition-all hover:bg-[#3b7bbd] hover:shadow-lg focus:ring-4 focus:ring-[#4A90D9]/30',
'disabled:pointer-events-none disabled:opacity-50'
)}
>
{executing
? <><RefreshCw className="h-4 w-4 animate-spin" />{t('executing')}</>
: <><Play className="h-4 w-4" />{t('execute')}</>}
</button>
</div>
)}
</div>
)}
</div>
)
}
// =============================================================================
// AutoRepairPanel
// =============================================================================
export function AutoRepairPanel() {
const t = useTranslations('autoRepair')
const tCommon = useTranslations('common')
const [stats, setStats] = useState<AutoRepairStats | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
const [statsError, setStatsError] = useState<string | null>(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<AbortController | null>(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])
const statCards = stats ? [
{
label: t('approvedPlaybooks'),
value: stats.approved_playbooks.toLocaleString(),
icon: Activity,
accent: 'text-[#4A90D9]',
bg: 'bg-[#f0f6ff]',
border: 'border-[#c7dcf7]',
},
{
label: t('highQualityPlaybooks'),
value: stats.high_quality_playbooks.toLocaleString(),
icon: CheckCircle2,
accent: 'text-[#17602a]',
bg: 'bg-[#f0faf2]',
border: 'border-[#8fc29a]',
},
{
label: t('totalExecutions'),
value: stats.total_executions.toLocaleString(),
icon: Zap,
accent: 'text-[#d97757]',
bg: 'bg-[#fff7f3]',
border: 'border-[#f5cdb8]',
},
{
label: t('overallSuccessRate'),
value: `${(stats.overall_success_rate * 100).toFixed(1)}%`,
icon: TrendingUp,
accent: 'text-[#8a5a08]',
bg: 'bg-[#fff7e8]',
border: 'border-[#d9b36f]',
},
] : []
return (
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Automation
</p>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-bold text-[#141413]">
{t('title')}
</h1>
<p className="mt-1 text-sm text-[#77736a]">
{t('subtitle')}
</p>
</div>
<button
onClick={fetchStats}
disabled={statsLoading}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#e0ddd4] bg-white text-[#77736a] transition hover:border-[#4A90D9] hover:text-[#4A90D9] disabled:cursor-not-allowed disabled:opacity-40"
aria-label={tCommon('refresh')}
>
<RefreshCw className={cn('h-4 w-4', statsLoading && 'animate-spin')} aria-hidden="true" />
</button>
</div>
{/* Error */}
{statsError && (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-[#9f2f25]" aria-hidden="true" />
<div>
<p className="font-semibold text-[#141413]">{t('error')}</p>
<p className="mt-1 font-mono text-xs text-[#77736a]">{statsError}</p>
</div>
</div>
)}
{/* Stats Cards */}
{stats && (
<div className="mb-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{statCards.map(({ label, value, icon: Icon, accent, bg, border }) => (
<div
key={label}
className={cn('rounded-xl border p-5 transition hover:shadow-sm', bg, border)}
>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
{label}
</p>
<Icon className={cn('h-4 w-4 shrink-0', accent)} aria-hidden="true" />
</div>
<p className={cn('mt-3 font-mono text-3xl font-bold tabular-nums', accent)}>
{value}
</p>
</div>
))}
</div>
)}
{/* Disposition summary */}
{disposition && disposition.total > 0 && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-[#8fc29a] bg-[#f0faf2] p-4 text-center">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#17602a]">{t('dispositionAuto')}</p>
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#17602a]">{disposition.auto_repair}</p>
</div>
<div className="rounded-xl border border-[#d9b36f] bg-[#fff7e8] p-4 text-center">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#8a5a08]">{t('dispositionHuman')}</p>
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#8a5a08]">{disposition.human_approved}</p>
</div>
<div className="rounded-xl border border-[#a491db] bg-[#f7f5ff] p-4 text-center">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#543b99]">{t('dispositionManual')}</p>
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#543b99]">{disposition.manual_resolved}</p>
</div>
<div className="rounded-xl border border-[#c7dcf7] bg-[#f0f6ff] p-4 text-center">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#1f5b9b]">{t('dispositionCold')}</p>
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#1f5b9b]">{disposition.cold_start_trust}</p>
</div>
</div>
)}
{/* Eligible indicator */}
{stats && (
<div className={cn(
'mb-6 flex items-center gap-4 rounded-xl border p-5 transition-shadow hover:shadow-sm',
stats.auto_repair_eligible
? 'border-[#8fc29a] bg-[#f0faf2]'
: 'border-[#e0ddd4] bg-white'
)}>
<div className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
stats.auto_repair_eligible ? 'bg-[#8fc29a]/20 text-[#17602a]' : 'bg-[#f5f4ed] text-[#77736a]'
)}>
{stats.auto_repair_eligible
? <CheckCircle2 className="h-5 w-5" aria-hidden="true" />
: <ShieldAlert className="h-5 w-5" aria-hidden="true" />}
</div>
<div>
<p className={cn('font-semibold', stats.auto_repair_eligible ? 'text-[#17602a]' : 'text-[#141413]')}>
{stats.auto_repair_eligible ? t('ready') : t('notReady')}
</p>
<p className={cn('mt-0.5 text-sm', stats.auto_repair_eligible ? 'text-[#17602a]/80' : 'text-[#77736a]')}>
{stats.auto_repair_eligible
? t('readyDesc', { count: stats.high_quality_playbooks })
: t('notReadyDesc')}
</p>
</div>
</div>
)}
{/* Incident evaluation list */}
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
<div className="flex items-center gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
<Wrench className="h-4 w-4 text-[#77736a]" aria-hidden="true" />
<h2 className="font-semibold text-[#141413]">
{t('incidentEval')}
</h2>
</div>
<div className="p-5">
{incidentsLoading ? (
<div className="flex flex-col items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-[#d8d3c7]" />
<span className="mt-3 text-sm text-[#77736a]">{tCommon('loading')}</span>
</div>
) : eligibleIncidents.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[#d8d3c7] bg-[#f5f4ed] py-12 text-[#77736a]">
<CheckCircle2 className="mb-3 h-8 w-8 opacity-50" />
<span className="text-sm font-medium">{t('noEligible')}</span>
</div>
) : (
<div className="flex flex-col gap-3">
{eligibleIncidents.map(incident => (
<IncidentEvalRow
key={incident.incident_id}
incidentId={incident.incident_id}
severity={incident.severity}
/>
))}
</div>
)}
</div>
</div>
</div>
)
}