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