feat(homepage): Metrics Strip 7指標視覺強化 + 真實資料串接
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
E2E Health Check / e2e-health (push) Has been cancelled

- 新增 podHealth/allRunning i18n key (zh-TW + en)
- Metrics Strip: 6個指標全部串接真實 API
  - 活躍事件: incidents count + P0 badge
  - 服務健康: dashboard services healthy/total + RPS sparkline
  - 待簽核: dashboard pendingApprovals + 橘色 badge
  - 自動處置率: incidents resolved rate + error rate sparkline
  - MTTR 均值: incidents resolved avg duration
  - POD 健康: dashboard services up/total + 顏色狀態
- Right panel 固定 530px 寬度 (55/45 比例)
- 禁止假數據: 無 API 資料時顯示 "--"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-02 21:27:59 +08:00
parent 48c65756da
commit 28bd06d7b3
3 changed files with 286 additions and 163 deletions

View File

@@ -140,7 +140,10 @@
"stable": "Stable",
"normal": "Normal",
"openclawEngine": "OPENCLAW COGNITIVE ENGINE",
"infrastructure": "INFRASTRUCTURE"
"infrastructure": "INFRASTRUCTURE",
"podHealth": "POD Health",
"allRunning": "All Running",
"servicesUp": "Services Up"
},
"openclaw": {
"name": "OpenClaw",

View File

@@ -141,7 +141,10 @@
"stable": "穩定",
"normal": "正常",
"openclawEngine": "OPENCLAW 認知引擎",
"infrastructure": "基礎架構"
"infrastructure": "基礎架構",
"podHealth": "POD 健康",
"allRunning": "全部運行中",
"servicesUp": "服務上線"
},
"openclaw": {
"name": "OpenClaw",

View File

@@ -1,45 +1,70 @@
'use client'
/**
* AWOOOI AI Center 主頁 (Task 10 重構)
* AWOOOI AI Center 主頁
* ====================================
* 2欄佈局Sidebar 由 AppLayout 提供): Feed + RightPanel
*
* 統帥鐵律: 使用真實數據 Hook禁止假數據
*
* @updated 2026-04-02 Claude Code — Metrics Strip 7指標視覺強化
* 串接: incidents(count/P0/MTTR/autoRemediation) + dashboard(serviceHealth/pendingApprovals/podHealth)
*/
import { useTranslations } from 'next-intl'
import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics'
import { useIncidents } from '@/hooks/useIncidents'
import { useHosts } from '@/stores/dashboard.store'
import { useHosts, useDashboardStore } from '@/stores/dashboard.store'
import { IncidentCard } from '@/components/incident'
import { OpenClawPanel } from '@/components/ai/openclaw-panel'
import { HostGrid, type HostInfo } from '@/components/infra/host-grid'
import { AppLayout } from '@/components/layout'
// =============================================================================
// Mini Sparkline (SVG inline)
// =============================================================================
function MiniSparkline({ values, color }: { values: number[]; color: string }) {
if (!values || values.length < 2) return null
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min || 1
const w = 48, h = 16
const pts = values.map((v, i) => {
const x = (i / (values.length - 1)) * w
const y = h - ((v - min) / range) * h
return `${x},${y}`
}).join(' ')
return (
<svg width={w} height={h} style={{ display: 'block', flexShrink: 0 }}>
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// =============================================================================
// Main Page
// =============================================================================
export default function Home({ params }: { params: { locale: string } }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const t = useTranslations()
const tDashboard = useTranslations('dashboard')
const tCommon = useTranslations('common')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const locale = params.locale
const hosts = useHosts()
// 統帥鐵律: 使用真實數據 Hook禁止假數據
// dashboard store — pending approvals + overall pod health
const pendingApprovals = useDashboardStore(s => s.pendingApprovals)
const dashboardHosts = useDashboardStore(s => s.hosts)
// Gold metrics (RPS / Error Rate / P99 / AI Success + sparklines)
const { metrics: pulseMetrics } = useGlobalPulseMetrics({
pollInterval: 30000,
enablePolling: true,
})
// Phase 7: 真實 Incident 數據
// Real incidents
const {
incidents,
pendingApprovals,
isLoading: isIncidentsLoading,
error: incidentsError,
} = useIncidents({
@@ -47,189 +72,281 @@ export default function Home({ params }: { params: { locale: string } }) {
enablePolling: true,
})
// Metrics Strip 計算
// ── Metrics 計算 ────────────────────────────────────────────────────────────
// 服務健康: dashboard hosts healthy services count
const { healthyServices, totalServices } = (() => {
let healthy = 0, total = 0
for (const h of dashboardHosts) {
for (const s of h.services) {
total++
if (s.status === 'up' || s.status === 'healthy') healthy++
}
}
return { healthyServices: healthy, totalServices: total }
})()
// P0 count
const p0Count = incidents?.filter(i => i.severity === 'P0').length ?? 0
// 自動處置率
const autoRemediationRate = (() => {
if (!incidents || incidents.length === 0) return '--'
if (!incidents?.length) return '--'
const resolved = incidents.filter(i => i.status === 'resolved' || i.status === 'closed').length
if (resolved === 0) return '0%'
return `${((resolved / incidents.length) * 100).toFixed(0)}%`
})()
// MTTR 均值
const mttrAvg = (() => {
if (!incidents || incidents.length === 0) return '--'
if (!incidents?.length) return '--'
const resolved = incidents.filter(i => i.updated_at && (i.status === 'resolved' || i.status === 'closed'))
if (resolved.length === 0) return '--'
const avgMs = resolved.reduce((sum, i) => {
return sum + (new Date(i.updated_at).getTime() - new Date(i.created_at).getTime())
}, 0) / resolved.length
if (!resolved.length) return '--'
const avgMs = resolved.reduce((sum, i) =>
sum + (new Date(i.updated_at).getTime() - new Date(i.created_at).getTime()), 0
) / resolved.length
const mins = Math.round(avgMs / 60000)
if (mins < 60) return `${mins}m`
return `${(mins / 60).toFixed(1)}h`
return mins < 60 ? `${mins}m` : `${(mins / 60).toFixed(1)}h`
})()
// Gold metric sparklines
const errorRateMetric = pulseMetrics?.find(m => m.label === 'Error Rate')
const rpsMetric = pulseMetrics?.find(m => m.label === 'RPS')
// POD health: healthy services / total
const podHealthStr = totalServices > 0 ? `${healthyServices}/${totalServices}` : '--'
const podAllRunning = totalServices > 0 && healthyServices === totalServices
// ── 7 Metrics Strip ─────────────────────────────────────────────────────────
type MetricItem = {
label: string
value: string | number
sub?: string
badge?: { text: string; color: string; bg: string }
sparkline?: { values: number[]; color: string }
valueColor?: string
}
const metrics: MetricItem[] = [
{
label: tDashboard('activeIncidents'),
value: incidents?.length ?? '--',
sub: p0Count > 0 ? undefined : tDashboard('stable'),
badge: p0Count > 0 ? { text: `P0 ×${p0Count}`, color: '#cc2200', bg: 'rgba(204,34,0,0.08)' } : undefined,
valueColor: p0Count > 0 ? '#cc2200' : undefined,
},
{
label: tDashboard('serviceHealth'),
value: totalServices > 0 ? `${healthyServices}/${totalServices}` : '--',
sub: tDashboard('normal'),
sparkline: rpsMetric?.trend ? { values: rpsMetric.trend, color: '#22C55E' } : undefined,
},
{
label: tDashboard('pendingApprovals'),
value: pendingApprovals ?? '--',
sub: pendingApprovals > 0 ? undefined : tDashboard('stable'),
badge: pendingApprovals > 0 ? { text: tDashboard('pendingApprovals'), color: '#F59E0B', bg: 'rgba(245,158,11,0.08)' } : undefined,
valueColor: pendingApprovals > 0 ? '#F59E0B' : undefined,
},
{
label: tDashboard('autoRemediationRate'),
value: autoRemediationRate,
sparkline: errorRateMetric?.trend ? { values: errorRateMetric.trend.map(v => 100 - v), color: '#4A90D9' } : undefined,
},
{
label: tDashboard('mttrAvg'),
value: mttrAvg,
},
{
label: tDashboard('podHealth'),
value: podHealthStr,
sub: podAllRunning ? tDashboard('allRunning') : undefined,
badge: !podAllRunning && totalServices > 0
? { text: `${totalServices - healthyServices} down`, color: '#cc2200', bg: 'rgba(204,34,0,0.08)' }
: undefined,
valueColor: podAllRunning ? '#22C55E' : totalServices > 0 ? '#cc2200' : undefined,
},
]
return (
<AppLayout locale={locale} showBackground={false}>
<div style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 64px)',
background: '#f5f4ed',
fontFamily: 'var(--font-body), monospace',
overflow: 'hidden',
margin: '-24px',
}}>
{/* ── Metrics Strip (50px) ────────────────────────────────────── */}
<div style={{
background: '#faf9f3',
borderBottom: '0.5px solid #e0ddd4',
height: 60,
padding: '0 20px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
gap: 0,
flexDirection: 'column',
height: 'calc(100vh - 64px)',
background: '#f5f4ed',
fontFamily: 'var(--font-body), monospace',
overflow: 'hidden',
margin: '-24px',
}}>
{[
{ label: tDashboard('activeIncidents'), value: incidents?.length ?? '--', sub: incidents?.filter((i) => i.severity === 'P0').length ? `+${incidents.filter((i) => i.severity === 'P0').length} P0` : tDashboard('stable') },
{ label: tDashboard('serviceHealth'), value: `${pulseMetrics?.length ?? '--'}/${pulseMetrics?.length ?? '--'}`, sub: tDashboard('normal') },
{ label: tDashboard('todayIncidents'), value: incidents?.length ?? '--', sub: '' },
{ label: tDashboard('autoRemediationRate'), value: autoRemediationRate, sub: '' },
{ label: tDashboard('mttrAvg'), value: mttrAvg, sub: '' },
].map((m, i, arr) => (
<div key={i} style={{
{/* ── Metrics Strip ─────────────────────────────────────────────────── */}
<div style={{
background: '#faf9f3',
borderBottom: '0.5px solid #e0ddd4',
height: 68,
padding: '0 20px',
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
}}>
{metrics.map((m, i) => (
<div key={i} style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 2,
paddingRight: 14,
borderRight: i < metrics.length - 1 ? '0.5px solid #e8e5dc' : 'none',
marginRight: i < metrics.length - 1 ? 14 : 0,
}}>
{/* Label */}
<span style={{ fontSize: 11, color: '#b0ad9f', letterSpacing: '1.5px', textTransform: 'uppercase' }}>
{m.label}
</span>
{/* Value row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontSize: 22,
fontWeight: 700,
color: m.valueColor ?? '#141413',
lineHeight: 1,
fontFamily: 'var(--font-body), monospace',
}}>
{String(m.value)}
</span>
{m.sparkline && (
<MiniSparkline values={m.sparkline.values} color={m.sparkline.color} />
)}
</div>
{/* Sub row */}
<div style={{ height: 16, display: 'flex', alignItems: 'center', gap: 4 }}>
{m.badge ? (
<span style={{
fontSize: 10, padding: '1px 6px',
background: m.badge.bg, color: m.badge.color,
border: `0.5px solid ${m.badge.color}40`,
borderRadius: 8, fontWeight: 600, whiteSpace: 'nowrap',
}}>
{m.badge.text}
</span>
) : m.sub ? (
<span style={{ fontSize: 11, color: '#87867f' }}>{m.sub}</span>
) : null}
</div>
</div>
))}
</div>
{/* ── 主體 2 欄 ─────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── Feed活躍事件 (flex:1) ───────────────────────────────────── */}
<div style={{
flex: 1,
borderRight: '0.5px solid #e0ddd4',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: 1,
paddingRight: 14,
borderRight: i < arr.length - 1 ? '0.5px solid #e8e5dc' : 'none',
marginRight: i < arr.length - 1 ? 14 : 0,
}}>
<span style={{ fontSize: 12, color: '#b0ad9f', letterSpacing: '2px', textTransform: 'uppercase' }}>
{m.label}
</span>
<span style={{ fontSize: 20, fontWeight: 700, color: '#141413', lineHeight: 1.1, fontFamily: 'var(--font-body), monospace' }}>
{String(m.value)}
</span>
{m.sub && <span style={{ fontSize: 12, color: '#87867f' }}>{m.sub}</span>}
</div>
))}
</div>
{/* ── 主體 2 欄Sidebar 外部)────────────────────────────────── */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── Feed活躍事件flex:1──────────────────────────────── */}
<div style={{
flex: 1,
borderRight: '0.5px solid #e0ddd4',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}>
{/* Feed 標題列 */}
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
display: 'flex',
alignItems: 'center',
gap: 8,
flexShrink: 0,
background: '#faf9f3',
}}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#141413', letterSpacing: '1px', textTransform: 'uppercase' }}>
{tDashboard('activeIncidents')}
</span>
{(incidents?.length ?? 0) > 0 && (
<span style={{
fontSize: 12, background: 'rgba(217,119,87,0.1)', color: '#a04010',
padding: '2px 8px', fontWeight: 700,
border: '0.5px solid rgba(217,119,87,0.25)', borderRadius: 10,
}}>
{incidents?.length}
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
display: 'flex', alignItems: 'center', gap: 8,
flexShrink: 0, background: '#faf9f3',
}}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#141413', letterSpacing: '1px', textTransform: 'uppercase' }}>
{tDashboard('activeIncidents')}
</span>
)}
{(incidents?.length ?? 0) > 0 && (
<span style={{
fontSize: 12, background: 'rgba(217,119,87,0.1)', color: '#a04010',
padding: '2px 8px', fontWeight: 700,
border: '0.5px solid rgba(217,119,87,0.25)', borderRadius: 10,
}}>
{incidents?.length}
</span>
)}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
{isIncidentsLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<span style={{ fontSize: 13, color: '#b0ad9f' }}>{tCommon('loading')}</span>
</div>
) : incidentsError ? (
<div style={{ padding: 16, fontSize: 13, color: '#cc2200' }}>{incidentsError}</div>
) : (incidents?.length ?? 0) === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 48, gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#22C55E' }} />
<span style={{ fontSize: 13, color: '#87867f' }}>{tDashboard('stable')} · 0 {tDashboard('activeIncidents')}</span>
</div>
) : (
incidents?.map((incident) => (
<IncidentCard
key={incident.incident_id}
incident={incident}
decision={incident.decision}
/>
))
)}
</div>
</div>
{/* Feed 內容 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
{isIncidentsLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<span style={{ fontSize: 13, color: '#b0ad9f' }}>{tCommon('loading')}</span>
</div>
) : incidentsError ? (
<div style={{ padding: 16, fontSize: 13, color: '#cc2200' }}>{incidentsError}</div>
) : (incidents?.length ?? 0) === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 48, gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#22C55E' }} />
<span style={{ fontSize: 13, color: '#87867f' }}>{tDashboard('stable')} · 0 {tDashboard('activeIncidents')}</span>
</div>
) : (
incidents?.map((incident) => (
<IncidentCard
key={incident.incident_id}
incident={incident}
decision={incident.decision}
{/* ── Right PanelAI + Infra (width:530px) ────────────────────── */}
<div style={{
width: 530,
flexShrink: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ flex: 1, overflowY: 'auto' }}>
{/* WoooClaw + Reasoning Stream */}
<div style={{ borderBottom: '0.5px solid #e0ddd4' }}>
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
fontSize: 13, fontWeight: 700, color: '#141413',
letterSpacing: '1px', textTransform: 'uppercase',
fontFamily: 'var(--font-body), monospace', background: '#faf9f3',
}}>
{tDashboard('openclawEngine')}
</div>
<OpenClawPanel
status={(incidents?.length ?? 0) > 0 ? 'analyzing' : 'patrolling'}
/>
))
)}
</div>
</div>
{/* ── Right PanelAI + Infraflex:1─────────────────────── */}
<div style={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ flex: 1, overflowY: 'auto' }}>
{/* 7.1 NemoClaw + Reasoning Stream */}
<div style={{ borderBottom: '0.5px solid #e0ddd4' }}>
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
fontSize: 13, fontWeight: 700, color: '#141413',
letterSpacing: '1px', textTransform: 'uppercase',
fontFamily: 'var(--font-body), monospace', background: '#faf9f3',
}}>
{tDashboard('openclawEngine')}
</div>
<OpenClawPanel
status={
(incidents?.length ?? 0) > 0 ? 'analyzing' : 'patrolling'
}
/>
</div>
{/* 7.3 基礎架構 2×2 Grid */}
<div style={{ borderBottom: '0.5px solid #e0ddd4' }}>
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
fontSize: 13, fontWeight: 700, color: '#141413',
letterSpacing: '1px', textTransform: 'uppercase',
fontFamily: 'var(--font-body), monospace', background: '#faf9f3',
}}>
{tDashboard('infrastructure')}
{/* 基礎架構 Grid */}
<div>
<div style={{
padding: '8px 12px',
borderBottom: '0.5px solid #e0ddd4',
fontSize: 13, fontWeight: 700, color: '#141413',
letterSpacing: '1px', textTransform: 'uppercase',
fontFamily: 'var(--font-body), monospace', background: '#faf9f3',
}}>
{tDashboard('infrastructure')}
</div>
<HostGrid hosts={hosts.map((h): HostInfo => ({
hostname: h.name,
ip: h.ip,
cpuPct: h.metrics?.cpu_percent ?? null,
ramPct: h.metrics?.memory_percent ?? null,
services: h.services.map(s => ({
name: s.name,
healthy: s.status === 'up' || s.status === 'healthy',
})),
}))} />
</div>
<HostGrid hosts={hosts.map((h): HostInfo => ({
hostname: h.name,
ip: h.ip,
cpuPct: h.metrics?.cpu_percent ?? null,
ramPct: h.metrics?.memory_percent ?? null,
services: h.services.map(s => ({
name: s.name,
healthy: s.status === 'up' || s.status === 'healthy',
})),
}))} />
</div>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
)
}