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