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 呈現層
254 lines
9.8 KiB
TypeScript
254 lines
9.8 KiB
TypeScript
'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>
|
||
)
|
||
}
|