Files
awoooi/apps/web/src/components/panels/SecurityPanel.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

254 lines
9.8 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'
/**
* SecurityPanel — 安全監控面板 (不含 AppLayout)
* Sprint 5R: 從 /security/page.tsx 抽取
* @updated 2026-07-03 — UI/UX 全面升級Tailwind + 彩色 Stat Cards + 問題列表
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import {
AlertCircle,
AlertTriangle,
Bug,
CheckCircle2,
Clock,
RefreshCw,
ShieldAlert,
ShieldCheck,
XCircle,
} 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 IssueStats {
total_issues: number
unresolved_issues: number
critical_count: number
error_count_24h: number
}
interface SentryIssue {
id: string
title: string
culprit: string | null
level: string
count: number
first_seen: string
last_seen: string
}
const LEVEL_CFG: Record<string, { badge: string; icon: typeof Bug }> = {
fatal: { badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]', icon: XCircle },
error: { badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]', icon: AlertCircle },
warning: { badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]', icon: AlertTriangle },
info: { badge: 'bg-[#f0f6ff] border-[#c7dcf7] text-[#1f5b9b]', icon: Bug },
debug: { badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]', icon: Bug },
}
export function SecurityPanel() {
const t = useTranslations('security')
const [stats, setStats] = useState<IssueStats | null>(null)
const [issues, setIssues] = useState<SentryIssue[]>([])
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/errors/stats`).then(r => r.ok ? r.json() : null).catch(() => null),
fetch(`${API_BASE}/api/v1/errors/issues?limit=20`).then(r => r.ok ? r.json() : { issues: [] }).catch(() => ({ issues: [] })),
])
.then(([statsData, listData]) => {
if (statsData) setStats(statsData)
setIssues(listData?.issues ?? [])
})
.catch(() => setError('load_failed'))
.finally(() => setLoading(false))
}
useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const statCards = stats ? [
{
label: t('totalIssues'),
value: stats.total_issues.toLocaleString(),
icon: Bug,
accent: 'text-[#141413]',
bg: 'bg-white',
border: 'border-[#e0ddd4]',
},
{
label: t('criticalIssues'),
value: stats.critical_count.toLocaleString(),
icon: XCircle,
accent: stats.critical_count > 0 ? 'text-[#9f2f25]' : 'text-[#17602a]',
bg: stats.critical_count > 0 ? 'bg-[#fff0ed]' : 'bg-[#f0faf2]',
border: stats.critical_count > 0 ? 'border-[#f5b8b8]' : 'border-[#8fc29a]',
},
{
label: t('unresolvedIssues'),
value: stats.unresolved_issues.toLocaleString(),
icon: AlertCircle,
accent: stats.unresolved_issues > 0 ? 'text-[#8a5a08]' : 'text-[#17602a]',
bg: stats.unresolved_issues > 0 ? 'bg-[#fff7e8]' : 'bg-[#f0faf2]',
border: stats.unresolved_issues > 0 ? 'border-[#d9b36f]' : 'border-[#8fc29a]',
},
{
label: t('errorRate'),
value: stats.error_count_24h != null ? `${stats.error_count_24h}/24h` : '—',
icon: Clock,
accent: 'text-[#4A90D9]',
bg: 'bg-[#f0f6ff]',
border: 'border-[#c7dcf7]',
},
] : []
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]">
Security
</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">
<ShieldAlert className="h-5 w-5 text-[#d97757]" 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].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>
)}
{/* Content */}
{!loading && !error && (
<div className="grid gap-4">
{/* Stat cards */}
{statCards.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{statCards.map(({ label, value, icon: Icon, accent, bg, border }) => (
<div
key={label}
className={cn('rounded-xl border p-5 transition hover:shadow-sm', bg, border)}
>
<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', accent)} aria-hidden="true" />
</div>
<p className={cn('mt-3 font-mono text-3xl font-bold tabular-nums', accent)}>{value}</p>
</div>
))}
</div>
)}
{/* Recent Issues */}
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
<div className="flex items-center justify-between gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
<div className="flex items-center gap-2">
{issues.length === 0 ? (
<ShieldCheck className="h-4 w-4 text-[#17602a]" aria-hidden="true" />
) : (
<ShieldAlert className="h-4 w-4 text-[#d97757]" aria-hidden="true" />
)}
<h2 className="font-semibold text-[#141413]">{t('recentIssues')}</h2>
</div>
<span className="rounded-full border border-[#e0ddd4] bg-white px-2.5 py-1 font-mono text-[11px] font-bold text-[#77736a]">
{issues.length}
</span>
</div>
{issues.length === 0 ? (
<div className="flex flex-col items-center py-12">
<ShieldCheck className="h-10 w-10 text-[#22C55E]" aria-hidden="true" />
<p className="mt-3 font-semibold text-[#17602a]">{t('noData')}</p>
</div>
) : (
<div className="divide-y divide-[#f5f4ed]">
{/* Header */}
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 bg-[#faf9f3] px-5 py-2.5">
{[t('issue'), t('level'), t('count'), t('lastSeen')].map(col => (
<span key={col} className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">{col}</span>
))}
</div>
{issues.map(issue => {
const cfg = LEVEL_CFG[issue.level] ?? LEVEL_CFG['debug']
const LvIcon = cfg.icon
return (
<div
key={issue.id}
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-4 px-5 py-3 transition hover:bg-[#faf9f3]"
>
<div className="min-w-0">
<p className="truncate font-medium text-[#141413]">{issue.title}</p>
{issue.culprit && (
<p className="mt-0.5 truncate font-mono text-[11px] text-[#77736a]">{issue.culprit}</p>
)}
</div>
<span className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase',
cfg.badge,
)}>
<LvIcon className="h-3 w-3" aria-hidden="true" />
{issue.level}
</span>
<span className="font-mono text-sm text-[#77736a]">{issue.count.toLocaleString()}</span>
<span className="font-mono text-[11px] text-[#77736a]">
{new Date(issue.last_seen).toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
)
})}
</div>
)}
</div>
</div>
)}
</div>
)
}