Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 35s
## 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>
441 lines
18 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|