477 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|