feat(auto-repair): 從佔位符升級為完整自動修復頁面

- 統計面板: Playbook 數/高品質數/執行次數/成功率
- 自動修復就緒狀態指示器
- 活躍 P1/P2 Incident 評估列表(可展開查看 Playbook 詳情)
- 執行修復按鈕(呼叫 /api/v1/auto-repair/execute)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-01 23:02:40 +08:00
parent cdf60bb8c8
commit b51241826c

View File

@@ -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<string, string> = {
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 (
<div className={cn(
'rounded-lg border p-4',
highlight ? 'bg-claw-blue/5 border-claw-blue/20' : 'bg-white border-nothing-gray-200'
)}>
<p className="text-[11px] font-mono text-nothing-gray-500 uppercase tracking-wider mb-1">{label}</p>
<p className={cn('text-2xl font-bold font-mono tabular-nums', highlight ? 'text-claw-blue' : 'text-nothing-black')}>
{value}
</p>
{sub && <p className="text-[11px] font-mono text-nothing-gray-400 mt-0.5">{sub}</p>}
</div>
)
}
// =============================================================================
// Incident Evaluate Row
// =============================================================================
function IncidentEvalRow({
incidentId, severity,
}: { incidentId: string; severity: string }) {
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())
} 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 (
<div className="border border-nothing-gray-200 rounded-lg overflow-hidden">
{/* Header row */}
<div
className="flex items-center gap-3 px-4 py-3 bg-nothing-gray-50 cursor-pointer hover:bg-nothing-gray-100 transition-colors"
onClick={() => setExpanded(v => !v)}
>
<span className={cn(
'px-2 py-0.5 rounded font-mono text-[11px] font-bold',
severity === 'P0' ? 'bg-status-critical/10 text-status-critical' :
severity === 'P1' ? 'bg-status-warning/10 text-status-warning' :
'bg-nothing-gray-100 text-nothing-gray-600'
)}>{severity}</span>
<span className="font-mono text-sm text-nothing-black flex-1 truncate">{incidentId}</span>
{loading && <RefreshCw className="w-4 h-4 animate-spin text-nothing-gray-400" />}
{!loading && eval_ && (
eval_.can_auto_repair
? <span className="flex items-center gap-1 text-[11px] font-mono text-status-healthy"><CheckCircle2 className="w-3.5 h-3.5" /></span>
: <span className="flex items-center gap-1 text-[11px] font-mono text-nothing-gray-400"><XCircle className="w-3.5 h-3.5" /></span>
)}
{expanded ? <ChevronUp className="w-4 h-4 text-nothing-gray-400" /> : <ChevronDown className="w-4 h-4 text-nothing-gray-400" />}
</div>
{/* Expanded detail */}
{expanded && eval_ && (
<div className="px-4 py-4 bg-white space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-[10px] font-mono text-nothing-gray-500 uppercase">Playbook</span>
<p className="font-mono text-nothing-black mt-0.5">{eval_.playbook_name ?? '—'}</p>
</div>
<div>
<span className="text-[10px] font-mono text-nothing-gray-500 uppercase"></span>
<p className="mt-0.5">
<span className={cn('px-2 py-0.5 rounded border text-[11px] font-mono font-bold', RISK_STYLE[eval_.risk_level] ?? RISK_STYLE.MEDIUM)}>
{eval_.risk_level}
</span>
</p>
</div>
{eval_.success_rate != null && (
<div>
<span className="text-[10px] font-mono text-nothing-gray-500 uppercase"></span>
<p className="font-mono text-status-healthy font-bold mt-0.5">{(eval_.success_rate * 100).toFixed(1)}%</p>
</div>
)}
{eval_.total_executions != null && (
<div>
<span className="text-[10px] font-mono text-nothing-gray-500 uppercase"></span>
<p className="font-mono text-nothing-black mt-0.5">{eval_.total_executions}</p>
</div>
)}
</div>
<div className="p-3 bg-nothing-gray-50 rounded-lg">
<span className="text-[10px] font-mono text-nothing-gray-500 uppercase"></span>
<p className="text-sm font-mono text-nothing-gray-700 mt-1">{eval_.reason}</p>
</div>
{/* Execute result */}
{result && (
<div className={cn(
'p-3 rounded-lg border',
result.success ? 'bg-status-healthy/5 border-status-healthy/20' : 'bg-status-critical/5 border-status-critical/20'
)}>
<div className="flex items-center gap-2 mb-2">
{result.success
? <CheckCircle2 className="w-4 h-4 text-status-healthy" />
: <XCircle className="w-4 h-4 text-status-critical" />}
<span className={cn('text-sm font-mono font-bold', result.success ? 'text-status-healthy' : 'text-status-critical')}>
{result.success ? `執行成功 (${result.execution_time_ms}ms)` : `執行失敗: ${result.error}`}
</span>
</div>
{result.executed_steps.length > 0 && (
<ul className="space-y-1">
{result.executed_steps.map((step, i) => (
<li key={i} className="text-xs font-mono text-nothing-gray-600 flex items-start gap-1.5">
<Zap className="w-3 h-3 mt-0.5 text-claw-blue flex-shrink-0" />
{step}
</li>
))}
</ul>
)}
</div>
)}
{/* Execute button */}
{eval_.can_auto_repair && !result && (
<button
onClick={handleExecute}
disabled={executing}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg',
'bg-claw-blue text-white font-mono text-sm font-semibold',
'hover:bg-claw-blue/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{executing
? <><RefreshCw className="w-4 h-4 animate-spin" />...</>
: <><Play className="w-4 h-4" /></>}
</button>
)}
</div>
)}
</div>
)
}
// =============================================================================
// Page
// =============================================================================
export default function AutoRepairPage({ params }: { params: { locale: string } }) {
const t = useTranslations()
const [stats, setStats] = useState<AutoRepairStats | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
const [statsError, setStatsError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(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 (
<AppLayout locale={params.locale}>
<ComingSoon
icon={Wrench}
title="自動修復"
description="自動修復任務管理"
/>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="font-heading text-2xl font-bold text-nothing-black flex items-center gap-2">
<Wrench className="w-6 h-6" />
{t('nav.autoRepair')}
</h2>
<p className="mt-1 text-sm text-nothing-gray-500 font-mono">
Playbook · MEDIUM · 95%
</p>
</div>
<button
onClick={fetchStats}
disabled={statsLoading}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
'text-xs font-mono bg-nothing-gray-100 text-nothing-gray-600',
'hover:bg-nothing-gray-200 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<RefreshCw className={cn('w-3.5 h-3.5', statsLoading && 'animate-spin')} />
{t('common.refresh')}
</button>
</div>
{/* Error */}
{statsError && (
<div className="mb-4 p-4 rounded-lg bg-status-critical/10 border border-status-critical/20 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-critical" />
<span className="text-sm font-mono text-status-critical">{statsError}</span>
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard
label="已批准 Playbooks"
value={stats.approved_playbooks}
/>
<StatCard
label="高品質 Playbooks"
value={stats.high_quality_playbooks}
sub="成功率 ≥ 95% · 執行 ≥ 10 次"
highlight
/>
<StatCard
label="總執行次數"
value={stats.total_executions}
/>
<StatCard
label="整體成功率"
value={`${(stats.overall_success_rate * 100).toFixed(1)}%`}
sub={stats.auto_repair_eligible ? '✓ 可啟用自動修復' : '尚無高品質 Playbook'}
/>
</div>
)}
{/* Eligible indicator */}
{stats && (
<div className={cn(
'flex items-center gap-3 p-4 rounded-lg border mb-6',
stats.auto_repair_eligible
? 'bg-status-healthy/5 border-status-healthy/20'
: 'bg-nothing-gray-50 border-nothing-gray-200'
)}>
{stats.auto_repair_eligible
? <CheckCircle2 className="w-5 h-5 text-status-healthy" />
: <ShieldAlert className="w-5 h-5 text-nothing-gray-400" />}
<div>
<p className={cn('text-sm font-mono font-semibold', stats.auto_repair_eligible ? 'text-status-healthy' : 'text-nothing-gray-600')}>
{stats.auto_repair_eligible ? '自動修復已就緒' : '自動修復未就緒'}
</p>
<p className="text-xs font-mono text-nothing-gray-400">
{stats.auto_repair_eligible
? `${stats.high_quality_playbooks} 個高品質 Playbook 可用`
: '需要至少 1 個高品質 Playbook成功率 ≥ 95%、執行 ≥ 10 次)'}
</p>
</div>
</div>
)}
{/* Incident evaluation list */}
<div>
<h3 className="text-[11px] font-mono text-nothing-gray-500 uppercase tracking-widest mb-3">
Incident P1/P2
</h3>
{incidentsLoading && (
<div className="flex items-center justify-center py-10">
<RefreshCw className="w-5 h-5 animate-spin text-nothing-gray-400" />
</div>
)}
{!incidentsLoading && eligibleIncidents.length === 0 && (
<div className="text-center py-10 border border-dashed border-nothing-gray-200 rounded-lg">
<CheckCircle2 className="w-8 h-8 text-status-healthy mx-auto mb-2" />
<p className="font-mono text-sm text-nothing-gray-500"> Incident</p>
</div>
)}
<div className="space-y-2">
{eligibleIncidents.map(incident => (
<IncidentEvalRow
key={incident.incident_id}
incidentId={incident.incident_id}
severity={incident.severity}
/>
))}
</div>
</div>
</AppLayout>
)
}