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

389 lines
16 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'
/**
* DeploymentsPanel — K3s 部署狀態面板 (不含 AppLayout)
* =====================================================
* Sprint 5: 從 /deployments/page.tsx 抽取
* 供原始頁面和整合頁面 (/operations) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 更新時間: 2026-07-03 — UI/UX 全面升級Tailwind + 時間軸事件 + 主機卡片
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
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 ?? ''
interface HostService {
name: string
status: string
port: number | null
latency_ms: number | null
}
interface Host {
ip: string
name: string
role: string
status: string
services: HostService[]
last_check: string
}
interface CicdEvent {
id: string
alertname: string
stage: string | null
status: string | null
severity: string | null
commit_sha: string | null
triggered_by: string | null
duration_seconds: number
summary: string | null
description: string | null
workflow_url: string | null
action_detail: string | null
needs_attention: boolean
created_at: string
}
interface CicdEventsResponse {
items?: CicdEvent[]
total: number
limit: number
}
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_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> = {
failed: 'cicd.status.failed',
pending: 'cicd.status.pending',
running: 'cicd.status.running',
success: 'cicd.status.success',
}
const CICD_STAGE_LABEL_KEYS: Record<string, string> = {
'build-and-deploy': 'cicd.stage.buildDeploy',
'code-review': 'cicd.stage.codeReview',
'post-deploy': 'cicd.stage.postDeploy',
'post-deploy-checks': 'cicd.stage.postDeployChecks',
'rollout-risk': 'cicd.stage.rolloutRisk',
tests: 'cicd.stage.tests',
}
function shortCommit(value: string | null | undefined) {
return value ? value.slice(0, 8) : null
}
export function DeploymentsPanel() {
const t = useTranslations('deployments')
const [hosts, setHosts] = useState<Host[]>([])
const [cicdEvents, setCicdEvents] = useState<CicdEvent[]>([])
const [cicdLoading, setCicdLoading] = useState(true)
const [cicdError, setCicdError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch(`${API_BASE}/api/v1/dashboard`)
.then(r => r.json())
.then(data => { setHosts(data.hosts ?? []); setLoading(false) })
.catch(err => { setError(String(err)); setLoading(false) })
}, [])
useEffect(() => {
fetch(`${API_BASE}/api/v1/platform/cicd/events?project_id=awoooi&limit=12`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
.then((data: CicdEventsResponse) => {
setCicdEvents(Array.isArray(data.items) ? data.items : [])
setCicdError(null)
setCicdLoading(false)
})
.catch(err => {
setCicdError(String(err))
setCicdEvents([])
setCicdLoading(false)
})
}, [])
const k3sHosts = hosts.filter(h => h.role === 'k3s' || h.ip.includes('120'))
const displayHosts = k3sHosts.length > 0 ? k3sHosts : hosts
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]">
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>
{/* 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 className="font-semibold text-[#141413]">{t('cicd.title')}</h2>
<p className="text-[11px] text-[#77736a]">{t('cicd.subtitle')}</p>
</div>
</div>
<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>
{/* 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 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]
return (
<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}
</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}
</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>
<p className="mt-1 truncate text-sm text-[#141413]">
{event.summary || event.action_detail || event.alertname}
</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>
{/* 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>
</div>
))}
</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>
)}
<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>
)
}