Files
awoooi/apps/web/src/components/panels/CompliancePanel.tsx
Your Name 0c740f70e3
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Failing after 1m4s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 1s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 30s
feat(ui): 全站 UI/UX 專業化重構 — Panel + Page Tailwind 化
## 改善範圍

### Panels (components/panels/)
- CostPanel: inline style → Tailwind + Stat Cards + 效能分佈長條圖 + Loading skeleton + Refresh
- ApmPanel: Tailwind + 彩色狀態徽章 + ring 發光 + Sparkline 趨勢圖
- AppsPanel: Tailwind + 卡片 Grid + 狀態 / 延遲指示器 + Summary Pills
- ServicesPanel: Tailwind + 3 欄 Summary Cards + 資源 CPU/RAM Progress Bar
- BillingPanel: Tailwind + Stat Cards + 成功/失敗進度條 + 操作類型分類圖
- DeploymentsPanel: Tailwind + CI/CD 時間軸事件 + 主機健康卡片帶服務 Grid
- SecurityPanel: Tailwind + 彩色 Stat Cards + 問題列表帶 Sentry 等級徽章
- CompliancePanel: Tailwind + 嚴重性分佈視覺化 + Auto-Repair 三欄指標面板
- ErrorsPanel: 頭部 Tailwind 化 + 統一 bg-[#f5f4ed] 背景

### Pages (app/[locale]/)
- operations: 5 個彩色功能模組卡片(帶描述、hover 上浮)
- automation: 3 個功能模組卡片 + 系統狀態橫幅
- help: Hero + 4 能力卡片 + 版本資訊表 + Docs 快捷連結
- notifications: Summary Stats + 頻道卡片(帶 Telegram/Slack 圖示)
- settings: 加上 bg-[#f5f4ed] 背景 + max-width wrapper
- topology: 標題列 Tailwind 化 + Network icon + Live 綠點

## 技術規範
- 零 inline style(除必要 dynamic 值)
- 全 Tailwind CSS
- TypeScript tsc --noEmit 通過
- 保留所有 API 邏輯不變,僅優化 UI 呈現層
2026-07-03 21:21:21 +08:00

239 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* CompliancePanel — 合規狀態面板 (不含 AppLayout)
* Sprint 5R: 從 /compliance/page.tsx 抽取
* @updated 2026-07-03 — UI/UX 全面升級Tailwind + 視覺化嚴重性分佈 + Stat Cards
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import {
AlertTriangle,
BarChart3,
CheckCircle2,
RefreshCw,
ShieldCheck,
Wrench,
XCircle,
Zap,
} from 'lucide-react'
import { cn } from '@/lib/utils'
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
}
const SEV_CFG: Record<string, { color: string; bg: string; border: string }> = {
P0: { color: 'text-[#9f2f25]', bg: 'bg-[#cc2200]', border: 'border-[#f5b8b8]' },
P1: { color: 'text-[#8a5a08]', bg: 'bg-[#F59E0B]', border: 'border-[#d9b36f]' },
P2: { color: 'text-[#1f5b9b]', bg: 'bg-[#4A90D9]', border: 'border-[#c7dcf7]' },
P3: { color: 'text-[#17602a]', bg: 'bg-[#22C55E]', border: 'border-[#8fc29a]' },
}
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)
const fetchData = () => {
setLoading(true)
setError(null)
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))
}
useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const resolvedColor =
summary && summary.resolved_rate >= 80 ? 'text-[#17602a]' :
summary && summary.resolved_rate >= 50 ? 'text-[#8a5a08]' :
'text-[#9f2f25]'
const resolvedBg =
summary && summary.resolved_rate >= 80 ? 'bg-[#f0faf2] border-[#8fc29a]' :
summary && summary.resolved_rate >= 50 ? 'bg-[#fff7e8] border-[#d9b36f]' :
'bg-[#fff0ed] border-[#f5b8b8]'
const maxSevCount = summary
? Math.max(...summary.severity_distribution.map(x => x.count), 1)
: 1
return (
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Compliance
</p>
<h1 className="mt-1 text-2xl font-bold text-[#141413]">{t('title')}</h1>
<p className="mt-1 text-sm text-[#77736a]">{t('subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-[#e0ddd4] bg-white">
<ShieldCheck className="h-5 w-5 text-[#17602a]" aria-hidden="true" />
</div>
<button
onClick={fetchData}
disabled={loading}
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-[#d97757] hover:text-[#d97757] disabled:opacity-40"
aria-label="Refresh"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} aria-hidden="true" />
</button>
</div>
</div>
{/* IwoooS Bridge */}
<div className="mb-4">
<IwoooSReadOnlyBridge />
</div>
{/* Loading */}
{loading && (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{[0,1,2,3,4].map(i => (
<div key={i} className="animate-pulse rounded-xl border border-[#e0ddd4] bg-white p-5">
<div className="mb-3 h-3 w-24 rounded bg-[#e0ddd4]" />
<div className="h-8 w-16 rounded bg-[#e0ddd4]" />
</div>
))}
</div>
)}
{/* Error */}
{!loading && error && (
<div className="flex items-start gap-3 rounded-xl border border-[#f5b8b8] bg-[#fff0ed] p-4">
<XCircle className="mt-0.5 h-5 w-5 shrink-0 text-[#cc2200]" aria-hidden="true" />
<p className="font-semibold text-[#141413]">{t('error')}</p>
</div>
)}
{/* Data */}
{!loading && !error && (
<div className="grid gap-4">
{/* Incident stats */}
{summary && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-[#e0ddd4] bg-white p-5 transition hover:shadow-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
{t('totalIncidents')}
</p>
<AlertTriangle className="h-4 w-4 shrink-0 text-[#d97757]" aria-hidden="true" />
</div>
<p className="mt-3 font-mono text-3xl font-bold tabular-nums text-[#141413]">
{summary.total_incidents.toLocaleString()}
</p>
<p className="mt-1 text-[11px] text-[#77736a]">{t('window30Days')}</p>
</div>
<div className={cn('rounded-xl border p-5 transition hover:shadow-sm', resolvedBg)}>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
{t('resolvedRate')}
</p>
<CheckCircle2 className={cn('h-4 w-4 shrink-0', resolvedColor)} aria-hidden="true" />
</div>
<p className={cn('mt-3 font-mono text-3xl font-bold tabular-nums', resolvedColor)}>
{summary.resolved_rate.toFixed(1)}%
</p>
</div>
</div>
)}
{/* Severity distribution */}
{summary && summary.severity_distribution.length > 0 && (
<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">
<BarChart3 className="h-4 w-4 text-[#d97757]" aria-hidden="true" />
<h2 className="font-semibold text-[#141413]">{t('severityDistribution')}</h2>
</div>
<div className="p-5">
<div className="grid grid-cols-4 gap-3">
{summary.severity_distribution.map(({ severity, count }) => {
const cfg = SEV_CFG[severity] ?? { color: 'text-[#77736a]', bg: 'bg-[#87867f]', border: 'border-[#e0ddd4]' }
const pct = maxSevCount > 0 ? (count / maxSevCount) * 100 : 0
return (
<div key={severity} className="flex flex-col items-center gap-2">
<span className={cn('font-mono text-xs font-bold', cfg.color)}>{severity}</span>
<div className="relative h-20 w-full overflow-hidden rounded bg-[#f5f4ed]">
<div
className={cn('absolute bottom-0 left-0 right-0 rounded transition-all duration-700', cfg.bg)}
style={{ height: `${pct}%` }}
/>
</div>
<span className={cn('font-mono text-xl font-bold', cfg.color)}>{count}</span>
</div>
)
})}
</div>
</div>
</div>
)}
{/* Auto-repair stats */}
{repairStats && (
<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-[#4A90D9]" aria-hidden="true" />
<h2 className="font-semibold text-[#141413]">{t('autoRepair')}</h2>
<span className={cn(
'ml-auto inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase',
repairStats.auto_repair_eligible
? 'border-[#8fc29a] bg-[#f0faf2] text-[#17602a]'
: 'border-[#f5b8b8] bg-[#fff0ed] text-[#9f2f25]',
)}>
{repairStats.auto_repair_eligible
? <><CheckCircle2 className="h-3 w-3" aria-hidden="true" />{t('yes')}</>
: <><XCircle className="h-3 w-3" aria-hidden="true" />{t('no')}</>
}
</span>
</div>
<div className="grid grid-cols-3 divide-x divide-[#f5f4ed]">
{[
{ label: t('approvedPlaybooks'), value: repairStats.approved_playbooks, sub: `${repairStats.high_quality_playbooks} ${t('highQualityPlaybooks')}`, icon: CheckCircle2, color: 'text-[#4A90D9]' },
{ label: t('executionSuccessRate'), value: `${(repairStats.overall_success_rate * 100).toFixed(1)}%`, sub: `${repairStats.total_executions} total`, icon: Zap, color: repairStats.overall_success_rate >= 0.8 ? 'text-[#17602a]' : 'text-[#8a5a08]' },
{ label: t('autoRepairEligible'), value: repairStats.auto_repair_eligible ? t('yes') : t('no'), sub: '', icon: Wrench, color: repairStats.auto_repair_eligible ? 'text-[#17602a]' : 'text-[#9f2f25]' },
].map(({ label, value, sub, icon: Icon, color }) => (
<div key={label} className="p-5">
<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', color)} aria-hidden="true" />
</div>
<p className={cn('mt-2 font-mono text-2xl font-bold tabular-nums', color)}>{value}</p>
{sub && <p className="mt-1 text-[11px] text-[#77736a]">{sub}</p>}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}