feat(awooop): surface cicd rollout evidence
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertTriangle, CheckCircle2, Clock3, GitCommit, RefreshCw } from 'lucide-react'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
|
||||
@@ -30,6 +31,29 @@ interface Host {
|
||||
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 STATUS_COLOR: Record<string, string> = {
|
||||
up: '#22C55E',
|
||||
healthy: '#22C55E',
|
||||
@@ -38,9 +62,37 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
unreachable: '#87867f',
|
||||
}
|
||||
|
||||
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_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> = {
|
||||
'code-review': 'cicd.stage.codeReview',
|
||||
'post-deploy': 'cicd.stage.postDeploy',
|
||||
'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)
|
||||
|
||||
@@ -51,6 +103,24 @@ export function DeploymentsPanel() {
|
||||
.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
|
||||
|
||||
@@ -60,6 +130,82 @@ export function DeploymentsPanel() {
|
||||
<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>
|
||||
<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" />
|
||||
<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>
|
||||
</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' }}>
|
||||
{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 }}>
|
||||
{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 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' }}>
|
||||
{statusKey ? t(statusKey as never) : status}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
|
||||
{stageKey ? t(stageKey as never) : stage}
|
||||
</div>
|
||||
</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' }}>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('loading')}</div>
|
||||
) : error ? (
|
||||
|
||||
Reference in New Issue
Block a user