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
## 改善範圍 ### 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 呈現層
239 lines
11 KiB
TypeScript
239 lines
11 KiB
TypeScript
'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>
|
||
)
|
||
}
|