Files
awoooi/apps/web/src/components/panels/AutoRepairPanel.tsx
Your Name d0591c54b0
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 35s
fix(security): 體健修復 — 7項 Critical/Major 安全問題全修
## Critical 修復 (C1-C5)
- C1: git rm --cached 03-secrets.yaml(CHANGE_ME 模板不再追蹤)
- C2: git rm --cached awoooi.db + .gitignore 加 *.db(SQLite HARD_RULES 違規)
- C3: sentry-tunnel SENTRY_HOST 改為 process.env fallback
- C4: config.py DATABASE_URL 移除 changeme default,改為必填
- C5: run_migration.py 改為 os.environ["DATABASE_URL"]

## Major 修復 (M1-M4)
- M1: auto_repair /execute 加 CSRF 保護 + AutoRepairPanel.tsx 同步
- M2: drift /rollback /adopt 加 CSRF 保護(/internal/scan 保持無 CSRF)
- M3: terminal /intent 加 CSRF 保護 + terminal.store.ts 同步
- M4: live-dashboard HOST_IPS + host-grid VIP 改為 env var

## 其他
- 新增 apps/web/.env.example(6 個 env var 說明)
- K8s deployment-web 補入 3 個新 env var
- 整合測試:新增 aider_event_repository + ai_router_feedback 真實 DB 測試
- test_terminal.py CSRF dependency override 修復

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:27:39 +08:00

441 lines
18 KiB
TypeScript

'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<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',
}
// =============================================================================
// StatCard
// =============================================================================
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-body text-nothing-gray-500 uppercase tracking-wider mb-1">{label}</p>
<p className={cn('text-2xl font-bold font-body tabular-nums', highlight ? 'text-claw-blue' : 'text-nothing-black')}>
{value}
</p>
{sub && <p className="text-[11px] font-body text-nothing-gray-400 mt-0.5">{sub}</p>}
</div>
)
}
// =============================================================================
// 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()
// 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<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="border border-nothing-gray-200 rounded-lg overflow-hidden">
<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-body 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-body 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-body text-status-healthy"><CheckCircle2 className="w-3.5 h-3.5" />{t('canAutoRepair')}</span>
: <span className="flex items-center gap-1 text-[11px] font-body text-nothing-gray-400"><XCircle className="w-3.5 h-3.5" />{t('notEligibleShort')}</span>
)}
{expanded ? <ChevronUp className="w-4 h-4 text-nothing-gray-400" /> : <ChevronDown className="w-4 h-4 text-nothing-gray-400" />}
</div>
{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-body text-nothing-gray-500 uppercase">Playbook</span>
<p className="font-body text-nothing-black mt-0.5">{eval_.playbook_name ?? '—'}</p>
</div>
<div>
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('riskLevel')}</span>
<p className="mt-0.5">
<span className={cn('px-2 py-0.5 rounded border text-[11px] font-body 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-body text-nothing-gray-500 uppercase">{t('successRate')}</span>
<p className="font-body 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-body text-nothing-gray-500 uppercase">{t('execCount')}</span>
<p className="font-body 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-body text-nothing-gray-500 uppercase">{t('decisionReason')}</span>
<p className="text-sm font-body text-nothing-gray-700 mt-1">{eval_.reason}</p>
</div>
{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-body font-bold', result.success ? 'text-status-healthy' : 'text-status-critical')}>
{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">
{result.executed_steps.map((step, i) => (
<li key={i} className="text-xs font-body 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>
)}
{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-body 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" />{t('executing')}</>
: <><Play className="w-4 h-4" />{t('execute')}</>}
</button>
)}
</div>
)}
</div>
)
}
// =============================================================================
// AutoRepairPanel
// =============================================================================
export function AutoRepairPanel() {
const t = useTranslations('autoRepair')
const tNav = useTranslations('nav')
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])
return (
<>
{/* 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" />
{tNav('autoRepair')}
</h2>
<p className="mt-1 text-sm text-nothing-gray-500 font-body">
{t('subtitle')}
</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-body 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')} />
{tCommon('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-body text-status-critical">{statsError}</span>
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard label={t('approvedPlaybooks')} value={stats.approved_playbooks} />
<StatCard label={t('highQualityPlaybooks')} value={stats.high_quality_playbooks} sub={t('highQualitySub')} highlight />
<StatCard label={t('totalExecutions')} value={stats.total_executions} />
<StatCard label={t('overallSuccessRate')} value={`${(stats.overall_success_rate * 100).toFixed(1)}%`} sub={stats.auto_repair_eligible ? t('eligible') : t('notEligible')} />
</div>
)}
{/* Disposition summary */}
{disposition && disposition.total > 0 && (
<div className="grid grid-cols-4 gap-2 mb-6">
<div className="rounded-lg border border-green-500/25 bg-green-500/5 p-3 text-center">
<p className="text-[10px] font-bold text-green-500 uppercase tracking-wider">{t('dispositionAuto')}</p>
<p className="text-xl font-bold text-green-500 tabular-nums mt-1">{disposition.auto_repair}</p>
</div>
<div className="rounded-lg border border-orange-500/25 bg-orange-500/5 p-3 text-center">
<p className="text-[10px] font-bold text-orange-500 uppercase tracking-wider">{t('dispositionHuman')}</p>
<p className="text-xl font-bold text-orange-500 tabular-nums mt-1">{disposition.human_approved}</p>
</div>
<div className="rounded-lg border border-purple-500/25 bg-purple-500/5 p-3 text-center">
<p className="text-[10px] font-bold text-purple-500 uppercase tracking-wider">{t('dispositionManual')}</p>
<p className="text-xl font-bold text-purple-500 tabular-nums mt-1">{disposition.manual_resolved}</p>
</div>
<div className="rounded-lg border border-blue-500/25 bg-blue-500/5 p-3 text-center">
<p className="text-[10px] font-bold text-blue-500 uppercase tracking-wider">{t('dispositionCold')}</p>
<p className="text-xl font-bold text-blue-500 tabular-nums mt-1">{disposition.cold_start_trust}</p>
</div>
</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-body font-semibold', stats.auto_repair_eligible ? 'text-status-healthy' : 'text-nothing-gray-600')}>
{stats.auto_repair_eligible ? t('ready') : t('notReady')}
</p>
<p className="text-xs font-body text-nothing-gray-400">
{stats.auto_repair_eligible
? t('readyDesc', { count: stats.high_quality_playbooks })
: t('notReadyDesc')}
</p>
</div>
</div>
)}
{/* Incident evaluation list */}
<div>
<h3 className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-widest mb-3">
{t('incidentEval')}
</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-body text-sm text-nothing-gray-500">{t('noEligible')}</p>
</div>
)}
<div className="space-y-2">
{eligibleIncidents.map(incident => (
<IncidentEvalRow
key={incident.incident_id}
incidentId={incident.incident_id}
severity={incident.severity}
/>
))}
</div>
</div>
</>
)
}