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

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

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

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

275 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* ServicesPanel — 服務目錄面板 (不含 AppLayout)
* ================================================
* Sprint 5: 從 /services/page.tsx 抽取
* 供原始頁面和整合頁面 (/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 ?? ''
interface ServiceItem {
name: string
host: string
status: string
cpu?: number
ram?: number
}
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() {
const t = useTranslations('services')
const tc = useTranslations('common')
const [services, setServices] = useState<ServiceItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const fetchData = () => {
setLoading(true)
setError(false)
fetch(`${API_BASE}/api/v1/dashboard`)
.then(r => r.json())
.then(data => {
const hosts: { name: string; services?: { name: string; status: string; cpu?: number; ram?: number }[] }[] = data?.hosts ?? []
const list: ServiceItem[] = []
hosts.forEach(h => {
(h.services ?? []).forEach(s => {
list.push({ name: s.name, host: h.name, status: s.status, cpu: s.cpu, ram: s.ram })
})
})
setServices(list)
})
.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 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>
<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>
)
}