feat(ui): 全站 UI/UX 專業化重構 — Panel + Page Tailwind 化
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 呈現層
This commit is contained in:
Your Name
2026-07-03 21:19:34 +08:00
parent 64c2a10948
commit 0c740f70e3
16 changed files with 2024 additions and 637 deletions

View File

@@ -3,11 +3,14 @@
/**
* ApmPanel — APM 黃金指標面板 (不含 AppLayout)
* Sprint 5: 從 /apm/page.tsx 抽取
* 更新時間: 2026-07-03 — UI/UX 全面升級Tailwind + 專業指標卡片
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { TimeSeriesChart } from '@/components/charts/time-series-chart'
import { Activity, AlertCircle, CheckCircle2, ExternalLink, RefreshCw, XCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
const SIGNOZ_URL = process.env.NEXT_PUBLIC_SIGNOZ_URL ?? '/signoz'
@@ -19,8 +22,16 @@ interface GoldMetricsResponse {
timestamp: string; service_name: string; metrics: GoldMetricItem[]
}
const STATUS_COLOR: Record<string, string> = { healthy: '#22C55E', warning: '#F59E0B', critical: '#cc2200', unknown: '#87867f' }
const STATUS_CHART_COLOR: Record<string, 'success' | 'warning' | 'error' | 'primary'> = { healthy: 'success', warning: 'warning', critical: 'error', unknown: 'primary' }
const STATUS_CFG: Record<string, { dot: string; ring: string; badge: string; icon: typeof CheckCircle2 }> = {
healthy: { dot: 'bg-[#22C55E]', ring: 'ring-[#22C55E]/20', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', icon: CheckCircle2 },
warning: { dot: 'bg-[#F59E0B]', ring: 'ring-[#F59E0B]/20', badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]', icon: AlertCircle },
critical: { dot: 'bg-[#cc2200]', ring: 'ring-[#cc2200]/20', badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]', icon: XCircle },
unknown: { dot: 'bg-[#87867f]', ring: 'ring-[#87867f]/20', badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]', icon: Activity },
}
const STATUS_CHART_COLOR: Record<string, 'success' | 'warning' | 'error' | 'primary'> = {
healthy: 'success', warning: 'warning', critical: 'error', unknown: 'primary',
}
export function ApmPanel() {
const t = useTranslations('apm')
@@ -28,62 +39,162 @@ export function ApmPanel() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = () => {
setLoading(true)
setError(null)
fetch(`${API_BASE}/api/v1/metrics/gold?service_name=awoooi-api&time_window_minutes=10`)
.then(r => r.json())
.then((d: GoldMetricsResponse) => { setData(d); setLoading(false) })
.catch(err => { setError(String(err)); setLoading(false) })
}, [])
}
useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Application Performance
</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 shrink-0 items-center gap-2">
<a
href={SIGNOZ_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-[#c7dcf7] bg-[#f0f6ff] px-3 py-2 text-[12px] font-semibold text-[#4A90D9] transition hover:bg-[#deeeff]"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
{t('openSignoz')}
</a>
<button
onClick={fetchData}
disabled={loading}
className="inline-flex h-9 w-9 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>
<a href={SIGNOZ_URL} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12, fontWeight: 600, color: '#4A90D9', border: '0.5px solid rgba(74,144,217,0.3)', borderRadius: 6, padding: '5px 12px', textDecoration: 'none', fontFamily: 'var(--font-body), monospace', background: 'rgba(74,144,217,0.05)' }}>
{t('openSignoz')}
</a>
</div>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontSize: 13 }}>{t('loading')}</div>
) : error ? (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontSize: 13 }}>{t('error')}</div>
) : data && data.metrics.length > 0 ? (
{/* Loading skeleton */}
{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-4 flex items-center justify-between">
<div className="h-3 w-24 rounded bg-[#e0ddd4]" />
<div className="h-4 w-12 rounded-full bg-[#e0ddd4]" />
</div>
<div className="h-8 w-20 rounded bg-[#e0ddd4]" />
<div className="mt-3 h-12 rounded bg-[#f5f4ed]" />
</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" />
<div>
<p className="font-semibold text-[#141413]">{t('error')}</p>
<p className="mt-1 font-mono text-xs text-[#77736a]">{error}</p>
</div>
</div>
)}
{/* Metrics grid */}
{!loading && !error && data && data.metrics.length > 0 && (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 12, marginBottom: 16 }}>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{data.metrics.map((m, i) => {
const trendPoints = (m.trend ?? []).map((v, idx) => ({ timestamp: idx, value: v }))
const hasTrend = trendPoints.length > 1
const chartColor = STATUS_CHART_COLOR[m.status] ?? 'primary'
const cfg = STATUS_CFG[m.status] ?? STATUS_CFG['unknown']
const StatusIcon = cfg.icon
return (
<div key={i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px 12px' }}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{m.label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', lineHeight: 1.2 }}>
{typeof m.value === 'number' ? m.value.toFixed(2) : m.value}
{m.unit && <span style={{ fontSize: 13, color: '#87867f', marginLeft: 4 }}>{m.unit}</span>}
<div
key={i}
className={cn(
'overflow-hidden rounded-xl border border-[#e0ddd4] bg-white transition hover:shadow-sm',
'ring-4',
cfg.ring,
)}
>
{/* Card header */}
<div className="flex items-center justify-between gap-2 px-5 pt-4">
<p className="truncate text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
{m.label}
</p>
<span className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold uppercase',
cfg.badge,
)}>
<StatusIcon className="h-3 w-3" aria-hidden="true" />
{m.status}
</span>
</div>
<div style={{ marginTop: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLOR[m.status] ?? '#87867f', display: 'inline-block' }} />
<span style={{ fontSize: 11, fontWeight: 600, color: STATUS_COLOR[m.status] ?? '#87867f', textTransform: 'uppercase' }}>{m.status}</span>
{/* Value */}
<div className="px-5 pb-2 pt-2">
<span className="font-mono text-3xl font-bold tabular-nums text-[#141413]">
{typeof m.value === 'number' ? m.value.toFixed(2) : m.value}
</span>
{m.unit && (
<span className="ml-1.5 text-sm font-medium text-[#77736a]">{m.unit}</span>
)}
</div>
{hasTrend && <TimeSeriesChart data={trendPoints} height={48} color={chartColor} unit={m.unit ?? undefined} showYAxis={false} showGradient={true} className="mt-1" />}
{/* Sparkline */}
{hasTrend && (
<TimeSeriesChart
data={trendPoints}
height={52}
color={chartColor}
unit={m.unit ?? undefined}
showYAxis={false}
showGradient={true}
className="px-0"
/>
)}
{!hasTrend && <div className="h-2" />}
</div>
)
})}
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '12px 16px' }}>
<span style={{ fontSize: 11, color: '#87867f' }}>
Service: <strong style={{ color: '#141413' }}>{data.service_name}</strong>{' · '}{new Date(data.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })}
{/* Footer metadata */}
<div className="mt-4 flex items-center gap-3 rounded-lg border border-[#e0ddd4] bg-white px-4 py-2.5">
<div className={cn('h-2 w-2 rounded-full', STATUS_CFG[data.metrics[0]?.status ?? 'unknown']?.dot)} />
<span className="font-mono text-[11px] text-[#77736a]">
Service: <strong className="text-[#141413]">{data.service_name}</strong>
</span>
<span className="ml-auto font-mono text-[11px] text-[#77736a]">
{new Date(data.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })}
</span>
</div>
</>
) : (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '60px 24px', textAlign: 'center' }}>
<div style={{ fontSize: 32, color: '#e0ddd4', marginBottom: 16 }}></div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#141413', marginBottom: 8 }}>{t('noData')}</div>
<div style={{ fontSize: 12, color: '#87867f', maxWidth: 340, margin: '0 auto' }}>{t('noDataDescription')}</div>
)}
{/* No data */}
{!loading && !error && data && data.metrics.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-[#e0ddd4] bg-white py-16">
<Activity className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
<p className="mt-4 font-semibold text-[#141413]">{t('noData')}</p>
<p className="mt-1 text-sm text-[#77736a]">{t('noDataDescription')}</p>
</div>
)}
{!loading && !error && !data && (
<div className="flex flex-col items-center justify-center rounded-xl border border-[#e0ddd4] bg-white py-16">
<Activity className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
<p className="mt-4 font-semibold text-[#141413]">{t('noData')}</p>
<p className="mt-1 text-sm text-[#77736a]">{t('noDataDescription')}</p>
</div>
)}
</div>

View File

@@ -7,10 +7,22 @@
* 供原始頁面和整合頁面 (/observability) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 更新時間: 2026-07-03 — UI/UX 全面升級Tailwind + 卡片 Grid + 狀態徽章
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import {
AlertCircle,
CheckCircle2,
Clock,
Globe,
RefreshCw,
Server,
WifiOff,
XCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -28,12 +40,51 @@ interface Host {
services: HostService[]
}
const STATUS_COLOR: Record<string, string> = {
up: '#22C55E',
healthy: '#22C55E',
down: '#cc2200',
degraded: '#F59E0B',
unreachable: '#87867f',
interface ServiceRow extends HostService {
hostName: string
hostIp: string
}
const STATUS_CFG: Record<string, {
dot: string
dotPulse?: string
badge: string
icon: typeof CheckCircle2
label: string
}> = {
up: { dot: 'bg-[#22C55E]', dotPulse: 'animate-pulse', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', icon: CheckCircle2, label: 'UP' },
healthy: { dot: 'bg-[#22C55E]', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]', icon: CheckCircle2, label: 'Healthy' },
down: { dot: 'bg-[#cc2200]', dotPulse: 'animate-pulse', badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]', icon: XCircle, label: 'DOWN' },
degraded: { dot: 'bg-[#F59E0B]', badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]', icon: AlertCircle, label: 'Degraded' },
unreachable: { dot: 'bg-[#87867f]', badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]', icon: WifiOff, label: 'N/A' },
}
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CFG[status] ?? STATUS_CFG['unreachable']
const Icon = cfg.icon
return (
<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,
)}>
<span className={cn('h-1.5 w-1.5 rounded-full', cfg.dot, cfg.dotPulse)} />
{cfg.label}
</span>
)
}
function LatencyBadge({ ms }: { ms: number | null }) {
if (ms === null) return <span className="font-mono text-xs text-[#b0ad9f]"></span>
const color =
ms < 50 ? 'text-[#17602a]' :
ms < 200 ? 'text-[#8a5a08]' :
'text-[#9f2f25]'
return (
<span className={cn('inline-flex items-center gap-1 font-mono text-xs font-semibold', color)}>
<Clock className="h-3 w-3" aria-hidden="true" />
{ms.toFixed(0)}ms
</span>
)
}
export function AppsPanel() {
@@ -42,62 +93,156 @@ export function AppsPanel() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = () => {
setLoading(true)
setError(null)
fetch(`${API_BASE}/api/v1/dashboard`)
.then(r => r.json())
.then(data => { setHosts(data.hosts ?? []); setLoading(false) })
.catch(err => { setError(String(err)); setLoading(false) })
}, [])
}
const allServices = hosts.flatMap(h =>
useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const allServices: ServiceRow[] = hosts.flatMap(h =>
h.services.map(s => ({ ...s, hostName: h.name, hostIp: h.ip }))
)
const totalUp = allServices.filter(s => s.status === 'up' || s.status === 'healthy').length
const totalDown = allServices.filter(s => s.status === 'down').length
const totalDegraded = allServices.filter(s => s.status === 'degraded').length
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22C55E', display: 'inline-block' }} />
{t('title')} ({loading ? '...' : allServices.length})
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Services
</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>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
) : error ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
) : allServices.length === 0 ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noApps')}</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('service'), t('host'), t('port'), t('latency'), t('status')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{allServices.map((s, i) => (
<tr key={i} style={{ borderBottom: '0.5px solid #f0ede4' }}>
<td style={{ padding: '8px 14px', fontWeight: 500, color: '#141413' }}>{s.name}</td>
<td style={{ padding: '8px 14px', color: '#87867f', fontSize: 12 }}>{s.hostName} <span style={{ color: '#c0bdb4' }}>({s.hostIp})</span></td>
<td style={{ padding: '8px 14px', color: '#87867f' }}>{s.port ?? '—'}</td>
<td style={{ padding: '8px 14px', color: '#87867f' }}>{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'}</td>
<td style={{ padding: '8px 14px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: STATUS_COLOR[s.status] ?? '#87867f' }}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: STATUS_COLOR[s.status] ?? '#87867f', display: 'inline-block' }} />
{s.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
<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>
{/* Summary pills */}
{!loading && allServices.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
<div className="flex items-center gap-2 rounded-lg border border-[#8fc29a] bg-[#f0faf2] px-3 py-1.5">
<CheckCircle2 className="h-4 w-4 text-[#17602a]" aria-hidden="true" />
<span className="font-mono text-sm font-bold text-[#17602a]">{totalUp}</span>
<span className="text-xs text-[#77736a]">{t('service')}</span>
</div>
{totalDown > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-[#f5b8b8] bg-[#fff0ed] px-3 py-1.5">
<XCircle className="h-4 w-4 text-[#9f2f25]" aria-hidden="true" />
<span className="font-mono text-sm font-bold text-[#9f2f25]">{totalDown}</span>
<span className="text-xs text-[#77736a]">Down</span>
</div>
)}
{totalDegraded > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-[#d9b36f] bg-[#fff7e8] px-3 py-1.5">
<AlertCircle className="h-4 w-4 text-[#8a5a08]" aria-hidden="true" />
<span className="font-mono text-sm font-bold text-[#8a5a08]">{totalDegraded}</span>
<span className="text-xs text-[#77736a]">Degraded</span>
</div>
)}
</div>
)}
{/* Loading skeleton */}
{loading && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[0,1,2,3,4,5].map(i => (
<div key={i} className="animate-pulse rounded-xl border border-[#e0ddd4] bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<div className="h-4 w-32 rounded bg-[#e0ddd4]" />
<div className="h-5 w-16 rounded-full bg-[#e0ddd4]" />
</div>
<div className="h-3 w-24 rounded bg-[#f5f4ed]" />
<div className="mt-3 h-3 w-16 rounded bg-[#f5f4ed]" />
</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" />
<div>
<p className="font-semibold text-[#141413]">{t('error')}</p>
<p className="mt-1 font-mono text-xs text-[#77736a]">{error}</p>
</div>
</div>
)}
{/* Service cards grid */}
{!loading && !error && allServices.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{allServices.map((s, i) => {
const cfg = STATUS_CFG[s.status] ?? STATUS_CFG['unreachable']
return (
<div
key={i}
data-testid={`app-service-card-${s.name}`}
className={cn(
'group rounded-xl border bg-white p-4 transition hover:shadow-sm',
s.status === 'down' ? 'border-[#f5b8b8]' :
s.status === 'degraded' ? 'border-[#d9b36f]' :
'border-[#e0ddd4]',
)}
>
{/* Service name + status */}
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className={cn('mt-0.5 h-2 w-2 shrink-0 rounded-full', cfg.dot, cfg.dotPulse)} />
<p className="min-w-0 truncate font-semibold text-[#141413]">{s.name}</p>
</div>
<StatusBadge status={s.status} />
</div>
{/* Host info */}
<div className="mt-3 flex items-center gap-1.5 text-xs text-[#77736a]">
<Server className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{s.hostName}</span>
<span className="text-[#c0bdb4]">({s.hostIp})</span>
</div>
{/* Port + Latency */}
<div className="mt-3 flex items-center gap-4 border-t border-[#f5f4ed] pt-3">
{s.port != null && (
<div className="flex items-center gap-1.5 text-xs text-[#77736a]">
<Globe className="h-3.5 w-3.5" aria-hidden="true" />
<span className="font-mono text-[#141413]">:{s.port}</span>
</div>
)}
<div className="ml-auto">
<LatencyBadge ms={s.latency_ms} />
</div>
</div>
</div>
)
})}
</div>
)}
{/* Empty */}
{!loading && !error && allServices.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-[#e0ddd4] bg-white py-16">
<Server className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
<p className="mt-4 font-semibold text-[#141413]">{t('noApps')}</p>
<p className="mt-1 text-sm text-[#77736a]">{t('subtitle')}</p>
</div>
)}
</div>
)
}

View File

@@ -7,10 +7,21 @@
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 更新時間: 2026-07-03 — UI/UX 全面升級Tailwind + Stat Cards + 分類視覺化
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import {
Activity,
AlertCircle,
BarChart3,
CheckCircle2,
Clock,
RefreshCw,
XCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -31,80 +42,217 @@ export function BillingPanel() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = () => {
setLoading(true)
setError(null)
fetch(`${API_BASE}/api/v1/audit-logs/stats`)
.then(r => r.json())
.then((d: AuditStats) => { setStats(d); setLoading(false) })
.catch(err => { setError(String(err)); setLoading(false) })
}, [])
}
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Operations
</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>
<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>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
) : error ? (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
) : stats ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12, marginBottom: 16 }}>
{/* 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" />
<div>
<p className="font-semibold text-[#141413]">{t('error')}</p>
<p className="mt-1 font-mono text-xs text-[#77736a]">{error}</p>
</div>
</div>
)}
{/* Data */}
{!loading && !error && stats && (
<div className="grid gap-4">
{/* Stat cards */}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{[
{ label: t('totalUsage'), value: stats.total_executions },
{ label: t('last24h'), value: stats.last_24h_count },
{ label: t('successRate'), value: `${(stats.success_rate * 100).toFixed(1)}%` },
{ label: t('avgDuration'), value: stats.avg_duration_ms ? `${stats.avg_duration_ms.toFixed(0)}ms` : '—' },
].map(card => (
<div key={card.label} style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{card.label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{card.value}</div>
{
label: t('totalUsage'),
value: stats.total_executions.toLocaleString(),
icon: Activity,
accent: 'text-[#4A90D9]',
bg: 'bg-[#f0f6ff]',
border: 'border-[#c7dcf7]',
},
{
label: t('last24h'),
value: stats.last_24h_count.toLocaleString(),
icon: Clock,
accent: 'text-[#d97757]',
bg: 'bg-[#fff7f3]',
border: 'border-[#f5cdb8]',
},
{
label: t('successRate'),
value: `${(stats.success_rate * 100).toFixed(1)}%`,
icon: CheckCircle2,
accent: 'text-[#17602a]',
bg: 'bg-[#f0faf2]',
border: 'border-[#8fc29a]',
},
{
label: t('avgDuration'),
value: stats.avg_duration_ms != null ? `${stats.avg_duration_ms.toFixed(0)}ms` : '—',
icon: BarChart3,
accent: 'text-[#8a5a08]',
bg: 'bg-[#fff7e8]',
border: 'border-[#d9b36f]',
},
].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>
{Object.keys(stats.by_operation_type).length > 0 && (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden', marginBottom: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
By Operation Type
{/* Success vs Failure bar */}
{(stats.success_count > 0 || stats.failure_count > 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">
<Activity className="h-4 w-4 text-[#4A90D9]" aria-hidden="true" />
<h2 className="font-semibold text-[#141413]">Success / Failure</h2>
</div>
<div className="p-5">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-xs text-[#17602a]">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
<span className="font-mono font-bold">{stats.success_count.toLocaleString()}</span>
</span>
<div className="flex-1 overflow-hidden rounded-full bg-[#f5f4ed]" style={{ height: 8 }}>
<div
className="h-full rounded-full bg-gradient-to-r from-[#22C55E] to-[#4A90D9] transition-all duration-700"
style={{ width: `${(stats.success_rate * 100).toFixed(1)}%` }}
/>
</div>
<span className="flex items-center gap-1.5 text-xs text-[#9f2f25]">
<AlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
<span className="font-mono font-bold">{stats.failure_count.toLocaleString()}</span>
</span>
</div>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<tbody>
{Object.entries(stats.by_operation_type).sort(([, a], [, b]) => b - a).map(([op, count]) => (
<tr key={op} style={{ borderBottom: '0.5px solid #f0ede4' }}>
<td style={{ padding: '7px 14px', fontWeight: 500, color: '#141413' }}>{op}</td>
<td style={{ padding: '7px 14px', color: '#87867f', textAlign: 'right' }}>{count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{Object.keys(stats.by_namespace).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', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
By Namespace
{/* By Operation Type */}
{Object.keys(stats.by_operation_type).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('byOperationType')}</h2>
</div>
<div className="divide-y divide-[#f5f4ed]">
{Object.entries(stats.by_operation_type)
.sort(([, a], [, b]) => b - a)
.map(([op, count]) => {
const max = Math.max(...Object.values(stats.by_operation_type))
const pct = max > 0 ? (count / max) * 100 : 0
return (
<div key={op} className="flex items-center gap-3 px-5 py-2.5">
<span className="w-40 min-w-0 truncate text-sm font-medium text-[#141413]">{op}</span>
<div className="flex-1 overflow-hidden rounded-full bg-[#f5f4ed]" style={{ height: 4 }}>
<div
className="h-full rounded-full bg-[#4A90D9] transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right font-mono text-sm font-bold text-[#141413]">
{count.toLocaleString()}
</span>
</div>
)
})}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<tbody>
{Object.entries(stats.by_namespace).sort(([, a], [, b]) => b - a).map(([ns, count]) => (
<tr key={ns} style={{ borderBottom: '0.5px solid #f0ede4' }}>
<td style={{ padding: '7px 14px', fontWeight: 500, color: '#141413' }}>{ns}</td>
<td style={{ padding: '7px 14px', color: '#87867f', textAlign: 'right' }}>{count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
) : (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
{/* By Namespace */}
{Object.keys(stats.by_namespace).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-[#8a5a08]" aria-hidden="true" />
<h2 className="font-semibold text-[#141413]">{t('byNamespace')}</h2>
</div>
<div className="divide-y divide-[#f5f4ed]">
{Object.entries(stats.by_namespace)
.sort(([, a], [, b]) => b - a)
.map(([ns, count]) => {
const max = Math.max(...Object.values(stats.by_namespace))
const pct = max > 0 ? (count / max) * 100 : 0
return (
<div key={ns} className="flex items-center gap-3 px-5 py-2.5">
<span className="w-40 min-w-0 truncate font-mono text-sm text-[#141413]">{ns}</span>
<div className="flex-1 overflow-hidden rounded-full bg-[#f5f4ed]" style={{ height: 4 }}>
<div
className="h-full rounded-full bg-[#d97757] transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right font-mono text-sm font-bold text-[#141413]">
{count.toLocaleString()}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* No data */}
{!loading && !error && !stats && (
<div className="flex flex-col items-center justify-center rounded-xl border border-[#e0ddd4] bg-white py-16">
<Activity className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
<p className="mt-4 font-semibold text-[#141413]">{t('noData')}</p>
</div>
)}
</div>
)

View File

@@ -3,11 +3,22 @@
/**
* CompliancePanel — 合規狀態面板 (不含 AppLayout)
* Sprint 5R: 從 /compliance/page.tsx 抽取
* @updated 2026-04-09 Claude Opus 4.6 Asia/Taipei
* @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 ?? ''
@@ -26,6 +37,13 @@ interface AutoRepairStats {
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)
@@ -33,7 +51,9 @@ export function CompliancePanel() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
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),
@@ -44,77 +64,174 @@ export function CompliancePanel() {
})
.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' }
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 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 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>
<IwoooSReadOnlyBridge />
{/* IwoooS Bridge */}
<div className="mb-4">
<IwoooSReadOnlyBridge />
</div>
{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 }}>{t('window30Days')}</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 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' }}>
<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)}%
</div>
</p>
</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>
</div>
)}
{/* Severity distribution */}
{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' }}>
{t('severityDistribution')}
<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 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 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>
)

View File

@@ -7,10 +7,13 @@
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 更新時間: 2026-07-03 — UI/UX 全面升級Tailwind + Stat Cards + 視覺化分佈圖
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { TrendingUp, Zap, CheckCircle2, Star, AlertCircle, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -24,70 +27,181 @@ interface AIPerformance {
effectiveness_distribution: Record<string, number>
}
const SCORE_CONFIG = [
{ score: 1, color: 'text-[#cc2200]', bg: 'bg-[#cc2200]', label: '極差' },
{ score: 2, color: 'text-[#F59E0B]', bg: 'bg-[#F59E0B]', label: '差' },
{ score: 3, color: 'text-[#87867f]', bg: 'bg-[#87867f]', label: '普通' },
{ score: 4, color: 'text-[#4A90D9]', bg: 'bg-[#4A90D9]', label: '好' },
{ score: 5, color: 'text-[#22C55E]', bg: 'bg-[#22C55E]', label: '極優' },
]
export function CostPanel() {
const t = useTranslations('cost')
const [data, setData] = useState<AIPerformance | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = () => {
setLoading(true)
setError(null)
fetch(`${API_BASE}/api/v1/stats/ai-performance?days=30`)
.then(r => r.json())
.then((d: AIPerformance) => { setData(d); setLoading(false) })
.catch(err => { setError(String(err)); setLoading(false) })
}, [])
}
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const statCards = data ? [
{
label: t('totalProposals'),
value: data.total_proposals.toLocaleString(),
icon: TrendingUp,
accent: 'text-[#4A90D9]',
bg: 'bg-[#f0f6ff]',
border: 'border-[#c7dcf7]',
},
{
label: t('executionRate'),
value: `${data.execution_rate.toFixed(1)}%`,
icon: Zap,
accent: 'text-[#d97757]',
bg: 'bg-[#fff7f3]',
border: 'border-[#f5cdb8]',
},
{
label: t('successRate'),
value: `${data.success_rate.toFixed(1)}%`,
icon: CheckCircle2,
accent: 'text-[#17602a]',
bg: 'bg-[#f0faf2]',
border: 'border-[#8fc29a]',
},
{
label: t('avgEffectiveness'),
value: data.avg_effectiveness != null ? data.avg_effectiveness.toFixed(2) : '—',
icon: Star,
accent: 'text-[#8a5a08]',
bg: 'bg-[#fff7e8]',
border: 'border-[#d9b36f]',
},
] : []
const maxDistCount = data
? Math.max(...[1,2,3,4,5].map(s => data.effectiveness_distribution[String(s)] ?? 0), 1)
: 1
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
AI Performance
</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>
<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:cursor-not-allowed disabled:opacity-40"
aria-label="Refresh"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} aria-hidden="true" />
</button>
</div>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
) : error ? (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
) : data ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12, marginBottom: 16 }}>
{[
{ label: t('totalProposals'), value: data.total_proposals },
{ label: t('executionRate'), value: `${(data.execution_rate).toFixed(1)}%` },
{ label: t('successRate'), value: `${(data.success_rate).toFixed(1)}%` },
{ label: t('avgEffectiveness'), value: data.avg_effectiveness ? data.avg_effectiveness.toFixed(2) : '—' },
].map(card => (
<div key={card.label} style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{card.label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{card.value}</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-[#f5cdb8] bg-[#fff7f3] p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-[#d97757]" aria-hidden="true" />
<div>
<p className="font-semibold text-[#141413]">{t('error')}</p>
<p className="mt-1 font-mono text-xs text-[#77736a]">{error}</p>
</div>
</div>
)}
{/* Data */}
{!loading && !error && data && (
<div className="grid gap-4">
{/* Stat Cards */}
<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>
{data.effectiveness_distribution && Object.keys(data.effectiveness_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', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
Effectiveness Distribution (15)
{/* Effectiveness Distribution */}
{Object.keys(data.effectiveness_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">
<Star className="h-4 w-4 text-[#d97757]" aria-hidden="true" />
<h2 className="font-semibold text-[#141413]">
{t('effectivenessDistribution')}
</h2>
<span className="ml-auto font-mono text-[11px] text-[#77736a]">1 5</span>
</div>
<div style={{ display: 'flex', gap: 0 }}>
{[1, 2, 3, 4, 5].map(score => {
const count = data.effectiveness_distribution[String(score)] ?? 0
const barColors = ['#cc2200', '#F59E0B', '#87867f', '#4A90D9', '#22C55E']
return (
<div key={score} style={{ flex: 1, padding: '12px 14px', borderRight: '0.5px solid #f0ede4', textAlign: 'center' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: barColors[score - 1], fontFamily: 'var(--font-body), monospace', marginBottom: 4 }}>{score}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{count}</div>
</div>
)
})}
<div className="p-5">
<div className="grid grid-cols-5 gap-3">
{SCORE_CONFIG.map(({ score, color, bg, label }) => {
const count = data.effectiveness_distribution[String(score)] ?? 0
const pct = maxDistCount > 0 ? (count / maxDistCount) * 100 : 0
return (
<div key={score} className="flex flex-col items-center gap-2">
<span className={cn('text-[11px] font-bold uppercase', color)}>{label}</span>
{/* Mini bar */}
<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', bg)}
style={{ height: `${pct}%` }}
/>
</div>
<span className={cn('font-mono text-xl font-bold', color)}>{count}</span>
<span className="font-mono text-[11px] text-[#77736a]">{score}</span>
</div>
)
})}
</div>
</div>
</div>
)}
</>
) : (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
</div>
)}
{/* No data */}
{!loading && !error && !data && (
<div className="flex flex-col items-center justify-center rounded-xl border border-[#e0ddd4] bg-white py-16">
<TrendingUp className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
<p className="mt-4 font-semibold text-[#141413]">{t('noData')}</p>
<p className="mt-1 text-sm text-[#77736a]">{t('subtitle')}</p>
</div>
)}
</div>
)

View File

@@ -7,11 +7,23 @@
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 更新時間: 2026-07-03 — UI/UX 全面升級Tailwind + 時間軸事件 + 主機卡片
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { AlertTriangle, CheckCircle2, Clock3, GitCommit, RefreshCw } from 'lucide-react'
import {
AlertTriangle,
CheckCircle2,
Clock3,
ExternalLink,
GitCommit,
RefreshCw,
Rocket,
Server,
XCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -54,19 +66,19 @@ interface CicdEventsResponse {
limit: number
}
const STATUS_COLOR: Record<string, string> = {
up: '#22C55E',
healthy: '#22C55E',
down: '#cc2200',
degraded: '#F59E0B',
unreachable: '#87867f',
const HOST_STATUS_CFG: Record<string, { dot: string; dotPulse?: string; badge: string }> = {
up: { dot: 'bg-[#22C55E]', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]' },
healthy: { dot: 'bg-[#22C55E]', badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]' },
down: { dot: 'bg-[#cc2200]', dotPulse: 'animate-pulse', badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]' },
degraded: { dot: 'bg-[#F59E0B]', badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]' },
unreachable: { dot: 'bg-[#87867f]', badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]' },
}
const CICD_STATUS_STYLE: Record<string, { border: string; background: string; color: string }> = {
success: { border: '#9bc7a4', background: '#f0faf2', color: '#17602a' },
running: { border: '#9bb6d9', background: '#eef5ff', color: '#1f5b9b' },
pending: { border: '#d9b36f', background: '#fff7e8', color: '#8a5a08' },
failed: { border: '#e2a29b', background: '#fff0ef', color: '#9f2f25' },
const CICD_STATUS_CFG: Record<string, { border: string; bg: string; text: string; icon: typeof CheckCircle2 }> = {
success: { border: 'border-[#8fc29a]', bg: 'bg-[#f0faf2]', text: 'text-[#17602a]', icon: CheckCircle2 },
running: { border: 'border-[#c7dcf7]', bg: 'bg-[#f0f6ff]', text: 'text-[#1f5b9b]', icon: RefreshCw },
pending: { border: 'border-[#d9b36f]', bg: 'bg-[#fff7e8]', text: 'text-[#8a5a08]', icon: Clock3 },
failed: { border: 'border-[#f5b8b8]', bg: 'bg-[#fff0ed]', text: 'text-[#9f2f25]', icon: XCircle },
}
const CICD_STATUS_LABEL_KEYS: Record<string, string> = {
@@ -127,134 +139,249 @@ export function DeploymentsPanel() {
const displayHosts = k3sHosts.length > 0 ? k3sHosts : hosts
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Deployments
</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 h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[#e0ddd4] bg-white">
<Rocket className="h-5 w-5 text-[#4A90D9]" aria-hidden="true" />
</div>
</div>
<section style={{ background: '#fff', border: '0.5px solid #e0ddd4', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<RefreshCw size={16} color="#d97757" aria-hidden="true" />
{/* CI/CD Events Timeline */}
<section className="mb-4 overflow-hidden rounded-2xl border border-[#e0ddd4] bg-white">
<div className="flex items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
<div className="flex items-center gap-2">
<GitCommit className="h-4 w-4 text-[#d97757]" aria-hidden="true" />
<div>
<h2 style={{ fontSize: 14, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('cicd.title')}</h2>
<p style={{ fontSize: 11, color: '#87867f', margin: '2px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('cicd.subtitle')}</p>
<h2 className="font-semibold text-[#141413]">{t('cicd.title')}</h2>
<p className="text-[11px] text-[#77736a]">{t('cicd.subtitle')}</p>
</div>
</div>
<span style={{ border: '0.5px solid #d8d3c7', background: '#fff', color: '#5f5b52', padding: '3px 8px', fontSize: 11, fontWeight: 600, fontFamily: 'var(--font-body), monospace' }}>
<span className="rounded-full border border-[#e0ddd4] bg-white px-3 py-1 font-mono text-[11px] font-bold text-[#77736a]">
{t('cicd.visibleCount', { count: cicdEvents.length })}
</span>
</div>
{cicdLoading ? (
<div style={{ padding: '22px 14px', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 12 }}>{t('cicd.loading')}</div>
) : cicdError ? (
<div style={{ padding: '22px 14px', color: '#9f2f25', fontFamily: 'var(--font-body), monospace', fontSize: 12 }}>{t('cicd.error')}</div>
) : cicdEvents.length === 0 ? (
<div style={{ padding: '22px 14px', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 12 }}>{t('cicd.empty')}</div>
) : (
<div style={{ display: 'grid', gap: 0 }}>
{/* Loading */}
{cicdLoading && (
<div className="divide-y divide-[#f5f4ed]">
{[0,1,2,3].map(i => (
<div key={i} className="flex animate-pulse items-start gap-4 px-5 py-4">
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-lg bg-[#e0ddd4]" />
<div className="flex-1 space-y-2">
<div className="h-3 w-32 rounded bg-[#e0ddd4]" />
<div className="h-3 w-48 rounded bg-[#f5f4ed]" />
</div>
</div>
))}
</div>
)}
{/* Error */}
{!cicdLoading && cicdError && (
<div className="flex items-center gap-2 px-5 py-4 text-sm text-[#9f2f25]">
<XCircle className="h-4 w-4" aria-hidden="true" />
{t('cicd.error')}
</div>
)}
{/* Empty */}
{!cicdLoading && !cicdError && cicdEvents.length === 0 && (
<div className="flex flex-col items-center py-10">
<GitCommit className="h-8 w-8 text-[#e0ddd4]" aria-hidden="true" />
<p className="mt-3 text-sm text-[#77736a]">{t('cicd.empty')}</p>
</div>
)}
{/* Timeline */}
{!cicdLoading && !cicdError && cicdEvents.length > 0 && (
<div className="divide-y divide-[#f5f4ed]">
{cicdEvents.map(event => {
const status = event.status ?? 'unknown'
const stage = event.stage ?? 'unknown'
const statusStyle = CICD_STATUS_STYLE[status] ?? { border: '#d8d3c7', background: '#fff', color: '#5f5b52' }
const cfg = CICD_STATUS_CFG[status] ?? { border: 'border-[#e0ddd4]', bg: 'bg-white', text: 'text-[#77736a]', icon: CheckCircle2 }
const CicdIcon = event.needs_attention ? AlertTriangle : cfg.icon
const statusKey = CICD_STATUS_LABEL_KEYS[status]
const stageKey = CICD_STAGE_LABEL_KEYS[stage]
const StatusIcon = event.needs_attention ? AlertTriangle : CheckCircle2
return (
<article key={event.id} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, padding: '11px 14px', borderTop: '0.5px solid #f0ede4', alignItems: 'start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, border: `0.5px solid ${statusStyle.border}`, background: statusStyle.background, color: statusStyle.color, flex: '0 0 auto' }}>
<StatusIcon size={14} aria-hidden="true" />
</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>
<article
key={event.id}
className="flex items-start gap-4 px-5 py-4 transition hover:bg-[#faf9f3]"
>
{/* Status icon */}
<div className={cn(
'mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border',
cfg.border, cfg.bg,
)}>
<CicdIcon className={cn('h-4 w-4', cfg.text)} aria-hidden="true" />
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className={cn('text-sm font-bold', cfg.text)}>
{statusKey ? t(statusKey as never) : status}
</div>
<div style={{ marginTop: 2, fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
</span>
<span className="rounded border border-[#e0ddd4] bg-[#f5f4ed] px-1.5 py-0.5 font-mono text-[10px] text-[#77736a]">
{stageKey ? t(stageKey as never) : stage}
</div>
</span>
{event.needs_attention && (
<span className="inline-flex items-center gap-1 rounded-full border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 text-[10px] font-bold text-[#8a5a08]">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
Needs Attention
</span>
)}
</div>
</div>
<div style={{ minWidth: 0, fontFamily: 'var(--font-body), monospace' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#5f5b52' }}>
<GitCommit size={13} aria-hidden="true" />
<span>{shortCommit(event.commit_sha) ?? t('cicd.emptyValue')}</span>
<span style={{ color: '#b8b2a6' }}>/</span>
<span>{event.triggered_by ?? t('cicd.emptyValue')}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 5, fontSize: 11, color: '#87867f' }}>
<Clock3 size={13} aria-hidden="true" />
<span>{event.created_at ? new Date(event.created_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }) : t('cicd.emptyValue')}</span>
<span style={{ color: '#b8b2a6' }}>/</span>
<span>{event.duration_seconds > 0 ? t('cicd.durationSeconds', { seconds: event.duration_seconds }) : t('cicd.durationNotRecorded')}</span>
</div>
</div>
<div style={{ minWidth: 0, fontFamily: 'var(--font-body), monospace' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#141413', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<p className="mt-1 truncate text-sm text-[#141413]">
{event.summary || event.action_detail || event.alertname}
</div>
<div style={{ marginTop: 4, fontSize: 11, lineHeight: 1.55, color: '#5f5b52', overflowWrap: 'anywhere' }}>
{event.description || event.alertname}
</div>
{event.workflow_url && (
<a href={event.workflow_url} target="_blank" rel="noreferrer" style={{ display: 'inline-flex', marginTop: 6, fontSize: 11, fontWeight: 600, color: '#1f5b9b', textDecoration: 'none' }}>
{t('cicd.openWorkflow')}
</a>
</p>
{event.description && (
<p className="mt-0.5 text-xs text-[#77736a] line-clamp-1">{event.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-[#77736a]">
{event.commit_sha && (
<span className="flex items-center gap-1.5">
<GitCommit className="h-3 w-3" aria-hidden="true" />
<span className="font-mono">{shortCommit(event.commit_sha)}</span>
</span>
)}
{event.triggered_by && (
<span className="flex items-center gap-1.5">
<span className="font-mono">{event.triggered_by}</span>
</span>
)}
{event.created_at && (
<span className="flex items-center gap-1.5">
<Clock3 className="h-3 w-3" aria-hidden="true" />
{new Date(event.created_at).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })}
</span>
)}
{event.duration_seconds > 0 && (
<span className="font-mono">{t('cicd.durationSeconds', { seconds: event.duration_seconds })}</span>
)}
</div>
</div>
{/* Workflow link */}
{event.workflow_url && (
<a
href={event.workflow_url}
target="_blank"
rel="noreferrer"
className="ml-auto shrink-0 self-start rounded-lg border border-[#c7dcf7] bg-[#f0f6ff] px-2.5 py-1.5 text-[11px] font-semibold text-[#4A90D9] transition hover:bg-[#deeeff]"
>
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)}
</article>
)
})}
</div>
)}
</section>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
) : error ? (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
) : displayHosts.length === 0 ? (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noDeployments')}</div>
) : (
displayHosts.map(host => (
<div key={host.ip} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLOR[host.status] ?? '#87867f', display: 'inline-block' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{host.name}</span>
<span style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>{host.ip}</span>
{/* Hosts */}
{loading && (
<div className="grid gap-3">
{[0,1].map(i => (
<div key={i} className="animate-pulse rounded-2xl border border-[#e0ddd4] bg-white p-5">
<div className="mb-4 h-4 w-32 rounded bg-[#e0ddd4]" />
<div className="grid gap-2">
{[0,1,2].map(j => <div key={j} className="h-3 rounded bg-[#f5f4ed]" />)}
</div>
<span style={{ fontSize: 10, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
{host.last_check ? new Date(host.last_check).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei' }) : '—'}
</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('service'), t('port'), t('latency'), t('status')].map(col => (
<th key={col} style={{ padding: '6px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{host.services.length === 0 ? (
<tr><td colSpan={4} style={{ padding: '16px 14px', textAlign: 'center', color: '#87867f', fontSize: 12 }}>{t('noDeployments')}</td></tr>
) : host.services.map((s, i) => (
<tr key={i} style={{ borderBottom: '0.5px solid #f0ede4' }}>
<td style={{ padding: '7px 14px', fontWeight: 500, color: '#141413' }}>{s.name}</td>
<td style={{ padding: '7px 14px', color: '#87867f' }}>{s.port ?? '—'}</td>
<td style={{ padding: '7px 14px', color: '#87867f' }}>{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'}</td>
<td style={{ padding: '7px 14px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: STATUS_COLOR[s.status] ?? '#87867f' }}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: STATUS_COLOR[s.status] ?? '#87867f', display: 'inline-block' }} />
{s.status}
))}
</div>
)}
{!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>
)}
{!loading && !error && displayHosts.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-2xl border border-[#e0ddd4] bg-white py-14">
<Server className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
<p className="mt-4 font-semibold text-[#141413]">{t('noDeployments')}</p>
</div>
)}
{!loading && !error && displayHosts.length > 0 && (
<div className="grid gap-4">
{displayHosts.map(host => {
const hcfg = HOST_STATUS_CFG[host.status] ?? HOST_STATUS_CFG['unreachable']
return (
<div key={host.ip} className="overflow-hidden rounded-2xl border border-[#e0ddd4] bg-white">
{/* Host header */}
<div className="flex items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
<div className="flex items-center gap-2.5">
<span className={cn('h-2.5 w-2.5 rounded-full', hcfg.dot, hcfg.dotPulse)} />
<span className="font-bold text-[#141413]">{host.name}</span>
<span className="font-mono text-[11px] text-[#77736a]">{host.ip}</span>
</div>
<div className="flex items-center gap-3">
{host.last_check && (
<span className="flex items-center gap-1.5 text-[11px] text-[#77736a]">
<Clock3 className="h-3 w-3" aria-hidden="true" />
{new Date(host.last_check).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei' })}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
))
)}
<span className={cn('rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase', hcfg.badge)}>
{host.status}
</span>
</div>
</div>
{/* Services table */}
{host.services.length === 0 ? (
<div className="py-8 text-center text-sm text-[#77736a]">{t('noDeployments')}</div>
) : (
<div className="divide-y divide-[#f5f4ed]">
{/* Table head */}
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 bg-[#faf9f3] px-5 py-2">
{[t('service'), t('port'), t('latency'), t('status')].map(col => (
<span key={col} className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
{col}
</span>
))}
</div>
{host.services.map((s, i) => {
const scfg = HOST_STATUS_CFG[s.status] ?? HOST_STATUS_CFG['unreachable']
return (
<div
key={i}
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-4 px-5 py-2.5 transition hover:bg-[#faf9f3]"
>
<span className="font-medium text-[#141413]">{s.name}</span>
<span className="font-mono text-sm text-[#77736a]">{s.port ?? '—'}</span>
<span className="font-mono text-sm text-[#77736a]">
{s.latency_ms != null ? `${s.latency_ms.toFixed(0)}ms` : '—'}
</span>
<span className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase',
scfg.badge,
)}>
<span className={cn('h-1.5 w-1.5 rounded-full', scfg.dot)} />
{s.status}
</span>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)

View File

@@ -28,24 +28,39 @@ export function ErrorsPanel() {
}
return (
<div style={{ padding: 24, maxWidth: 1280, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Bug style={{ width: 24, height: 24, color: '#555550' }} />
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6" style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-[#e0ddd4] bg-white">
<Bug className="h-5 w-5 text-[#cc2200]" aria-hidden="true" />
</div>
<div>
<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>
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">Sentry</p>
<h1 className="text-2xl font-bold text-[#141413]">{t('title')}</h1>
<p className="mt-0.5 text-sm text-[#77736a]">{t('subtitle')}</p>
</div>
</div>
<button onClick={refetch} disabled={loading} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', fontSize: 13, background: '#f0efe8', border: '0.5px solid #e0ddd4', borderRadius: 6, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.5 : 1 }}>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<button
onClick={refetch}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-[#e0ddd4] bg-white px-3 py-2 text-sm font-semibold text-[#77736a] transition hover:border-[#d97757] hover:text-[#d97757] disabled:cursor-not-allowed disabled:opacity-40"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
{loading ? t('loading') : t('refresh')}
</button>
</div>
<IwoooSReadOnlyBridge />
{error && <div style={{ marginBottom: 24, padding: 16, background: 'rgba(204,34,0,0.05)', border: '0.5px solid rgba(204,34,0,0.2)', borderRadius: 8 }}><p style={{ fontSize: 13, color: '#cc2200' }}>{error}</p></div>}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-6">
{error && (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#f5b8b8] bg-[#fff0ed] p-4">
<p className="text-sm text-[#cc2200]">{error}</p>
</div>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-1">
<ErrorOverviewCard stats={stats} loading={loading} changePercent={trends?.change_percent} />
<ErrorTrendChart data={trends} loading={loading} activePeriod={activePeriod} onPeriodChange={setPeriod} />
<UXAuditCard data={uxAuditData} loading={uxAuditLoading} error={uxAuditError} />
@@ -54,7 +69,10 @@ export function ErrorsPanel() {
<RecentIssuesList issues={issues} loading={loading} onIssueClick={handleIssueClick} />
</div>
</div>
<div style={{ marginTop: 24, paddingTop: 16, borderTop: '0.5px solid #e0ddd4' }}><p style={{ fontSize: 11, color: '#b0ad9f', textAlign: 'center' }}>{t('footerInfo')}</p></div>
<div className="mt-6 border-t border-[#e0ddd4] pt-4">
<p className="text-center font-mono text-[11px] text-[#b0ad9f]">{t('footerInfo')}</p>
</div>
</div>
)
}

View File

@@ -3,11 +3,23 @@
/**
* SecurityPanel — 安全監控面板 (不含 AppLayout)
* Sprint 5R: 從 /security/page.tsx 抽取
* @updated 2026-04-09 Claude Opus 4.6 Asia/Taipei
* @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 ?? ''
@@ -29,8 +41,12 @@ interface SentryIssue {
last_seen: string
}
const LEVEL_COLOR: Record<string, string> = {
fatal: '#cc2200', error: '#cc2200', warning: '#F59E0B', info: '#4A90D9', debug: '#87867f',
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() {
@@ -40,7 +56,9 @@ export function SecurityPanel() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
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: [] })),
@@ -51,77 +69,184 @@ export function SecurityPanel() {
})
.catch(() => setError('load_failed'))
.finally(() => setLoading(false))
}, [])
}
const cardStyle = { background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }
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 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 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>
<IwoooSReadOnlyBridge />
{/* IwoooS Bridge */}
<div className="mb-4">
<IwoooSReadOnlyBridge />
</div>
{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>
) : (
<>
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12, marginBottom: 16 }}>
{[
{ label: t('totalIssues'), value: stats.total_issues, color: '#141413' },
{ label: t('criticalIssues'), value: stats.critical_count, color: stats.critical_count > 0 ? '#cc2200' : '#22C55E' },
{ label: t('unresolvedIssues'), value: stats.unresolved_issues, color: stats.unresolved_issues > 0 ? '#F59E0B' : '#22C55E' },
{ label: t('errorRate'), value: stats.error_count_24h != null ? `${stats.error_count_24h}/24h` : '—', color: '#141413' },
].map(card => (
<div key={card.label} style={cardStyle}>
<div style={{ fontSize: 11, color: '#87867f', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{card.label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: card.color }}>{card.value}</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>
)}
<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' }}>
{t('recentIssues')} ({issues.length})
{/* 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 style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontSize: 13 }}>{t('noData')}</div>
<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>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('issue'), t('level'), t('count'), t('lastSeen')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{issues.map(issue => (
<tr key={issue.id} style={{ borderBottom: '0.5px solid #f0ede4' }}>
<td style={{ padding: '8px 14px', maxWidth: 300 }}>
<div style={{ fontWeight: 500, color: '#141413', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{issue.title}</div>
{issue.culprit && <div style={{ fontSize: 11, color: '#87867f', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{issue.culprit}</div>}
</td>
<td style={{ padding: '8px 14px' }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: LEVEL_COLOR[issue.level] ?? '#87867f' }}>{issue.level}</span>
</td>
<td style={{ padding: '8px 14px', color: '#87867f' }}>{issue.count}</td>
<td style={{ padding: '8px 14px', color: '#87867f', fontSize: 11 }}>
{new Date(issue.last_seen).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
</tr>
<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>
))}
</tbody>
</table>
</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>
)

View File

@@ -7,10 +7,22 @@
* 供原始頁面和整合頁面 (/observability) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 更新時間: 2026-07-03 — UI/UX 全面升級Tailwind + 健康矩陣 + 資源使用指示器
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import {
AlertCircle,
CheckCircle2,
Cpu,
HardDrive,
RefreshCw,
Server,
WifiOff,
XCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -22,11 +34,71 @@ interface ServiceItem {
ram?: number
}
const statusColor = (s: string) => {
if (s === 'healthy' || s === 'running') return '#4caf50'
if (s === 'warning') return '#ff9800'
if (s === 'critical' || s === 'error') return '#f44336'
return '#87867f'
const STATUS_CFG: Record<string, {
dot: string
dotPulse?: string
border: string
badge: string
icon: typeof CheckCircle2
}> = {
healthy: {
dot: 'bg-[#22C55E]',
border: 'border-l-[#22C55E]',
badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]',
icon: CheckCircle2,
},
running: {
dot: 'bg-[#22C55E]',
border: 'border-l-[#22C55E]',
badge: 'bg-[#f0faf2] border-[#8fc29a] text-[#17602a]',
icon: CheckCircle2,
},
warning: {
dot: 'bg-[#F59E0B]',
border: 'border-l-[#F59E0B]',
badge: 'bg-[#fff7e8] border-[#d9b36f] text-[#8a5a08]',
icon: AlertCircle,
},
critical: {
dot: 'bg-[#cc2200]',
dotPulse: 'animate-pulse',
border: 'border-l-[#cc2200]',
badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]',
icon: XCircle,
},
error: {
dot: 'bg-[#cc2200]',
dotPulse: 'animate-pulse',
border: 'border-l-[#cc2200]',
badge: 'bg-[#fff0ed] border-[#f5b8b8] text-[#9f2f25]',
icon: XCircle,
},
}
const fallbackCfg = {
dot: 'bg-[#87867f]',
border: 'border-l-[#87867f]',
badge: 'bg-[#f5f4ed] border-[#e0ddd4] text-[#77736a]',
icon: WifiOff,
}
function ResourceBar({ value, label, icon: Icon }: { value: number; label: string; icon: typeof Cpu }) {
const color =
value >= 85 ? 'bg-[#cc2200]' :
value >= 65 ? 'bg-[#F59E0B]' :
'bg-[#4A90D9]'
return (
<div className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5 shrink-0 text-[#77736a]" aria-hidden="true" />
<span className="w-8 text-right font-mono text-[11px] text-[#77736a]">{value}%</span>
<div className="flex-1 overflow-hidden rounded-full bg-[#f5f4ed]" style={{ height: 4 }}>
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${Math.min(value, 100)}%` }}
/>
</div>
</div>
)
}
export function ServicesPanel() {
@@ -36,7 +108,7 @@ export function ServicesPanel() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
useEffect(() => {
const fetchData = () => {
setLoading(true)
setError(false)
fetch(`${API_BASE}/api/v1/dashboard`)
@@ -53,67 +125,150 @@ export function ServicesPanel() {
})
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [])
}
useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const healthyCount = services.filter(s => s.status === 'healthy' || s.status === 'running').length
const warnCount = services.filter(s => s.status === 'warning').length
const critCount = services.filter(s => s.status === 'critical' || s.status === 'error').length
return (
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Infrastructure
</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>
{loading && (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
{tc('loading')}
</div>
)}
{!loading && error && (
<div style={{ padding: '32px', textAlign: 'center', color: '#f44336', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
{t('fetchError')}
</div>
)}
{!loading && !error && services.length === 0 && (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
{t('noServices')}
</div>
)}
{!loading && !error && services.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('name'), t('host'), t('status'), t('cpu'), t('ram')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{services.map((s, i) => (
<tr key={i} style={{ borderBottom: '0.5px solid #e0ddd4' }}>
<td style={{ padding: '10px 14px', color: '#141413', fontWeight: 500 }}>{s.name}</td>
<td style={{ padding: '10px 14px', color: '#87867f' }}>{s.host}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: statusColor(s.status), display: 'inline-block' }} />
<span style={{ color: '#141413' }}>{s.status}</span>
</span>
</td>
<td style={{ padding: '10px 14px', color: '#141413' }}>{s.cpu != null ? `${s.cpu}%` : '--'}</td>
<td style={{ padding: '10px 14px', color: '#141413' }}>{s.ram != null ? `${s.ram}%` : '--'}</td>
</tr>
))}
</tbody>
</table>
)}
<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>
{/* Summary row */}
{!loading && !error && services.length > 0 && (
<div className="mb-4 grid grid-cols-3 gap-2">
<div className="flex flex-col items-center rounded-lg border border-[#8fc29a] bg-[#f0faf2] p-3">
<span className="font-mono text-2xl font-bold text-[#17602a]">{healthyCount}</span>
<span className="mt-0.5 text-[11px] text-[#77736a]">Healthy</span>
</div>
<div className="flex flex-col items-center rounded-lg border border-[#d9b36f] bg-[#fff7e8] p-3">
<span className="font-mono text-2xl font-bold text-[#8a5a08]">{warnCount}</span>
<span className="mt-0.5 text-[11px] text-[#77736a]">Warning</span>
</div>
<div className="flex flex-col items-center rounded-lg border border-[#f5b8b8] bg-[#fff0ed] p-3">
<span className="font-mono text-2xl font-bold text-[#9f2f25]">{critCount}</span>
<span className="mt-0.5 text-[11px] text-[#77736a]">Critical</span>
</div>
</div>
)}
{/* Loading skeleton */}
{loading && (
<div className="grid gap-2">
{[0,1,2,3,4].map(i => (
<div key={i} className="animate-pulse rounded-xl border border-[#e0ddd4] bg-white p-4">
<div className="flex items-center justify-between">
<div className="h-4 w-40 rounded bg-[#e0ddd4]" />
<div className="h-5 w-16 rounded-full bg-[#e0ddd4]" />
</div>
<div className="mt-3 h-3 w-28 rounded bg-[#f5f4ed]" />
</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('fetchError')}</p>
</div>
)}
{/* Service list */}
{!loading && !error && services.length > 0 && (
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
{/* Table header */}
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-4 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-2.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
{t('name')} / {t('host')}
</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Resources
</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
{t('status')}
</span>
</div>
{/* Rows */}
{services.map((s, i) => {
const cfg = STATUS_CFG[s.status] ?? fallbackCfg
const Icon = cfg.icon
return (
<div
key={i}
data-testid={`service-row-${s.name}`}
className={cn(
'grid grid-cols-[1fr_auto_auto] items-center gap-4 border-b border-[#f5f4ed] px-4 py-3 last:border-0',
'border-l-2 transition hover:bg-[#faf9f3]',
cfg.border,
)}
>
{/* Name + host */}
<div className="min-w-0">
<p className="truncate font-semibold text-[#141413]">{s.name}</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-[#77736a]">
<Server className="h-3 w-3 shrink-0" aria-hidden="true" />
{s.host}
</p>
</div>
{/* Resource bars */}
<div className="w-36 space-y-1.5">
{s.cpu != null && (
<ResourceBar value={s.cpu} label="CPU" icon={Cpu} />
)}
{s.ram != null && (
<ResourceBar value={s.ram} label="RAM" icon={HardDrive} />
)}
{s.cpu == null && s.ram == null && (
<span className="font-mono text-xs text-[#b0ad9f]"></span>
)}
</div>
{/* Status badge */}
<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,
)}>
<Icon className="h-3 w-3" aria-hidden="true" />
{s.status}
</span>
</div>
)
})}
</div>
)}
{/* Empty */}
{!loading && !error && services.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-[#e0ddd4] bg-white py-16">
<Server className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
<p className="mt-4 font-semibold text-[#141413]">{tc('loading')}</p>
<p className="mt-1 text-sm text-[#77736a]">{t('noServices')}</p>
</div>
)}
</div>
)
}