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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user