Files
awoooi/apps/web/src/components/panels/CompliancePanel.tsx
2026-05-20 19:56:33 +08:00

122 lines
5.9 KiB
TypeScript

'use client'
/**
* CompliancePanel — 合規狀態面板 (不含 AppLayout)
* Sprint 5R: 從 /compliance/page.tsx 抽取
* @updated 2026-04-09 Claude Opus 4.6 Asia/Taipei
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { IwoooSReadOnlyBridge } from '@/components/security/iwooos-read-only-bridge'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface IncidentSummary {
total_incidents: number
resolved_rate: number
severity_distribution: Array<{ severity: string; count: number }>
}
interface AutoRepairStats {
approved_playbooks: number
high_quality_playbooks: number
total_executions: number
overall_success_rate: number
auto_repair_eligible: boolean
}
export function CompliancePanel() {
const t = useTranslations('compliance')
const [summary, setSummary] = useState<IncidentSummary | null>(null)
const [repairStats, setRepairStats] = useState<AutoRepairStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/api/v1/stats/incidents/summary?days=30`).then(r => r.ok ? r.json() : null).catch(() => null),
fetch(`${API_BASE}/api/v1/auto-repair/stats`).then(r => r.ok ? r.json() : null).catch(() => null),
])
.then(([s, r]) => {
if (s) setSummary(s)
if (r) setRepairStats(r)
})
.catch(() => setError('load_failed'))
.finally(() => setLoading(false))
}, [])
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
const sevColors: Record<string, string> = { P0: '#cc2200', P1: '#F59E0B', P2: '#4A90D9', P3: '#22C55E' }
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0 }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0' }}>{t('subtitle')}</p>
</div>
<IwoooSReadOnlyBridge />
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontSize: 13 }}>{t('loading')}</div>
) : error ? (
<div style={{ ...cardStyle, textAlign: 'center', color: '#cc2200', fontSize: 13 }}>{t('error')}</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))', gap: 12, marginBottom: 16 }}>
{summary && <>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('totalIncidents')}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413' }}>{summary.total_incidents}</div>
<div style={{ fontSize: 11, color: '#87867f', marginTop: 4 }}>30 days</div>
</div>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('resolvedRate')}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: summary.resolved_rate >= 80 ? '#22C55E' : summary.resolved_rate >= 50 ? '#F59E0B' : '#cc2200' }}>
{summary.resolved_rate.toFixed(1)}%
</div>
</div>
</>}
{repairStats && <>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('approvedPlaybooks')}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413' }}>{repairStats.approved_playbooks}</div>
<div style={{ fontSize: 11, color: '#87867f', marginTop: 4 }}>{t('highQualityPlaybooks')}: {repairStats.high_quality_playbooks}</div>
</div>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('executionSuccessRate')}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: repairStats.overall_success_rate >= 0.8 ? '#22C55E' : '#F59E0B' }}>
{(repairStats.overall_success_rate * 100).toFixed(1)}%
</div>
</div>
<div style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t('autoRepairEligible')}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: repairStats.auto_repair_eligible ? '#22C55E' : '#cc2200' }}>
{repairStats.auto_repair_eligible ? t('yes') : t('no')}
</div>
</div>
</>}
</div>
{summary && summary.severity_distribution.length > 0 && (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', color: '#141413' }}>
Severity Distribution
</div>
<div style={{ display: 'flex', gap: 0 }}>
{summary.severity_distribution.map(({ severity, count }) => (
<div key={severity} style={{ flex: 1, padding: '12px 14px', borderRight: '0.5px solid #f0ede4', textAlign: 'center' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: sevColors[severity] ?? '#87867f', marginBottom: 4 }}>{severity}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#141413' }}>{count}</div>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)
}