feat(web): S1 KPI Strip 改 5 張卡片 — Sprint 5R Phase 1B

- 7 指標分隔線 → 5 張 kpi-card 卡片橫排
- 系統健康(進度條) / 活動事件(P1:P2) / 自動修復率(進度條+↑5%) / 待審批 / 本週操作
- 移除龍蝦游泳列(統帥指示移除)
- 新增 weeklyOps 從 /api/v1/audit-logs/stats 取得

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 15:48:04 +08:00
parent 289dac6bd1
commit 7a2e07f74f

View File

@@ -650,96 +650,24 @@ export default function Home({ params }: { params: { locale: string } }) {
const podHealthStr = totalServices > 0 ? `${healthyServices}/${totalServices}` : '--'
const podAllRunning = totalServices > 0 && healthyServices === totalServices
// ── 7 Metrics Strip ─────────────────────────────────────────────────────────
// figma-v2 順序: 活躍事件 | 服務健康 | 待處理授權 | 今日事件 | 自動處置率 | MTTR 均值 | Pod 健康
// ── 5 KPI Cards (Sprint 5R 設計稿批准版) ────────────────────────────────────
const hasPendingApprovals = pendingApprovals !== null && pendingApprovals !== undefined && pendingApprovals > 0
const incidentCount = incidents?.length ?? 0
const todayIncidentCount = incidentCount
// P2 count
const p1Count = incidents?.filter(i => i.severity === 'P1').length ?? 0
const p2Count = incidents?.filter(i => i.severity === 'P2').length ?? 0
const metrics: MetricItem[] = [
{
label: tDashboard('activeIncidents'),
value: incidentCount > 0 ? incidentCount : '--',
sub: incidentCount === 0 ? tDashboard('stable') : undefined,
// figma-v2: P0 badge (紅) + P2 badge (藍),值橘色
extra: incidentCount > 0 ? (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{p0Count > 0 && (
<span style={{ background: 'rgba(204,34,0,0.1)', color: '#cc2200', padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>P0×{p0Count}</span>
)}
{p2Count > 0 && (
<span style={{ background: 'rgba(74,144,217,0.1)', color: '#4A90D9', padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 600 }}>P2×{p2Count}</span>
)}
</div>
) : undefined,
valueColor: incidentCount > 0 ? '#d97757' : undefined,
},
{
label: tDashboard('serviceHealth'),
value: totalServices > 0 ? `${healthyServices}/${totalServices}` : '--',
valueColor: '#22C55E',
// 固定4條按比例顯示健康數
extra: totalServices > 0 ? (
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
{Array.from({ length: 4 }).map((_, idx) => (
<span key={idx} style={{
display: 'inline-block', width: 10, height: 4, borderRadius: 2,
background: idx < Math.round((healthyServices / totalServices) * 4) ? '#22C55E' : '#e0ddd4',
}} />
))}
</div>
) : undefined,
},
{
label: tDashboard('pendingApprovals'),
value: pendingApprovals ?? '--',
sub: hasPendingApprovals ? undefined : tDashboard('stable'),
badge: hasPendingApprovals ? { text: tDashboard('awaitingConfirm'), color: '#F59E0B', bg: 'rgba(245,158,11,0.08)' } : undefined,
valueColor: hasPendingApprovals ? '#F59E0B' : undefined,
},
{
// figma-v2: 今日事件value-row 有橘色 ↑Nextra 有折線
label: tDashboard('todayIncidents'),
value: todayIncidentCount,
trend: todayIncidentCount > 0 ? { text: `${todayIncidentCount > 0 ? Math.max(1, Math.round(todayIncidentCount * 0.2)) : 0}`, color: '#d97757' } : undefined,
extra: todaySparkValues ? (
<MiniSparkline values={todaySparkValues} color="#d97757" />
) : undefined,
},
{
label: tDashboard('autoRemediationRate'),
value: autoRemediationRate,
trend: autoRemediationPct > 0 ? { text: `${autoRemediationPct > 5 ? 5 : autoRemediationPct}%`, color: '#22C55E' } : undefined,
extra: (
<div style={{ width: 60, height: 4, background: '#e0ddd4', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
width: `${autoRemediationPct}%`, height: '100%',
background: 'linear-gradient(90deg,#22C55E,#4ade80)', borderRadius: 2,
}} />
</div>
),
},
{
label: tDashboard('mttrAvg'),
value: mttrAvg,
trend: mttrTrend,
extra: mttrSparkValues ? (
<MiniSparkline values={mttrSparkValues} color="#22C55E" />
) : undefined,
},
{
label: tDashboard('podHealth'),
value: podHealthStr,
sub: podAllRunning
? tDashboard('allRunning')
: totalServices > 0 ? `${totalServices - healthyServices} down` : undefined,
valueColor: podAllRunning ? '#22C55E' : totalServices > 0 ? '#cc2200' : undefined,
},
]
// 本週操作數
const [weeklyOps, setWeeklyOps] = useState<number | null>(null)
useEffect(() => {
fetch(`${API_BASE}/api/v1/audit-logs/stats`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.total_executions != null) setWeeklyOps(d.total_executions) })
.catch(() => {})
}, [])
// 系統健康百分比
const systemHealthPct = totalServices > 0 ? Math.round((healthyServices / totalServices) * 100) : 0
// Sprint 5: 4 Tab 配置 (統帥批准 2026-04-08)
const alertsCount = incidents?.length ?? 0
@@ -787,104 +715,51 @@ export default function Home({ params }: { params: { locale: string } }) {
overflow: 'hidden',
}}>
{/* ── Metrics Strip ─────────────────────────────────────────────────── */}
<div style={{
background: '#fff',
borderBottom: '0.5px solid #e0ddd4',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
}}>
{/* Chibi 龍蝦游泳列 */}
<div style={{
height: 16,
position: 'relative',
overflow: 'hidden',
borderBottom: '0.5px dashed rgba(232,85,48,0.08)',
}}>
<style>{`
@keyframes swim-wide {
0% { transform: translateX(0) scaleX(1) }
47% { transform: translateX(1100px) scaleX(1) }
50% { transform: translateX(1100px) scaleX(-1) }
97% { transform: translateX(0) scaleX(-1) }
100% { transform: translateX(0) scaleX(1) }
}
@keyframes chibi-bob {
0%,100% { transform: translateY(0) }
50% { transform: translateY(-2px) }
}
.chibi-swim-anim { animation: swim-wide 25s linear infinite; position: absolute; top: 1px; left: 0; }
.chibi-bob-anim { animation: chibi-bob 0.7s ease-in-out infinite; display: inline-block; }
`}</style>
<div className="chibi-swim-anim">
<div className="chibi-bob-anim">
<svg width="18" height="14" viewBox="0 0 18 14" fill="none">
<ellipse cx="9" cy="10" rx="5" ry="4" fill="#E85530" opacity="0.9"/>
<circle cx="9" cy="6" r="3.5" fill="#E85530" opacity="0.9"/>
<circle cx="7.5" cy="5.2" r="0.9" fill="#fff" opacity="0.8"/>
<circle cx="10.5" cy="5.2" r="0.9" fill="#fff" opacity="0.8"/>
<path d="M3 8.5 Q0.5 7.5 1 10 Q1.5 11.5 3.5 11" stroke="#E85530" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
<ellipse cx="1" cy="10" rx="1.2" ry="1.5" fill="#E85530" opacity="0.7" transform="rotate(-10 1 10)"/>
<path d="M15 8.5 Q17.5 7.5 17 10 Q16.5 11.5 14.5 11" stroke="#E85530" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
<ellipse cx="17" cy="10" rx="1.2" ry="1.5" fill="#E85530" opacity="0.7" transform="rotate(10 17 10)"/>
<path d="M6.5 2.5 Q5 0.5 3.5 1" stroke="#b03a1a" strokeWidth="0.8" fill="none" strokeLinecap="round"/>
<path d="M11.5 2.5 Q13 0.5 14.5 1" stroke="#b03a1a" strokeWidth="0.8" fill="none" strokeLinecap="round"/>
<path d="M6 13 Q9 14.5 12 13" stroke="#E85530" strokeWidth="1" fill="none" strokeLinecap="round"/>
</svg>
{/* ── KPI Strip (5 卡片 — Sprint 5R 設計稿) ──────────────────────── */}
<div style={{ display: 'flex', gap: 12, padding: '10px 20px', flexShrink: 0 }}>
{/* 系統健康 */}
<div style={{ flex: 1, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '8px 12px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('serviceHealth')}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#22C55E', marginTop: 2 }}>
{totalServices > 0 ? `${Math.round((healthyServices / totalServices) * 100)}%` : '--'}
</div>
{totalServices > 0 && (
<div style={{ height: 3, borderRadius: 2, background: '#ebe8df', marginTop: 4, overflow: 'hidden' }}>
<div style={{ width: `${systemHealthPct}%`, height: '100%', borderRadius: 2, background: '#22C55E' }} />
</div>
)}
</div>
{/* 活動事件 */}
<div style={{ flex: 1, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '8px 12px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('activeIncidents')}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginTop: 2 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: incidentCount > 0 ? '#d97757' : '#141413' }}>{incidentCount || '--'}</span>
{incidentCount > 0 && <span style={{ fontSize: 9, color: '#87867f' }}>P1:{p1Count} P2:{p2Count}</span>}
</div>
</div>
{/* Metrics Row — figma-v2 完整複製 */}
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, gap: 0 }}>
{metrics.map((m, i) => (
<React.Fragment key={i}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
padding: '8px 16px',
minWidth: 120,
}}>
{/* Label — figma: font-size:11px */}
<span style={{ fontSize: 11, color: '#b0ad9f', letterSpacing: '1.5px', textTransform: 'uppercase', marginBottom: 4, fontWeight: 500, whiteSpace: 'nowrap', height: 16, lineHeight: '16px' }}>
{m.label}
</span>
{/* Value row — figma: height:32px值 + trend 箭頭同行 */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, height: 32 }}>
<span style={{
fontSize: 22, fontWeight: 700,
color: m.valueColor ?? '#141413',
lineHeight: 1,
fontFamily: 'var(--font-body), "DM Mono", monospace',
}}>
{String(m.value)}
</span>
{m.trend && (
<span style={{ fontSize: 11, color: m.trend.color, fontWeight: 700 }}>{m.trend.text}</span>
)}
</div>
{/* Extra row — figma: height:20px margin-top:4px */}
<div style={{ height: 20, display: 'flex', alignItems: 'center', gap: 4, marginTop: 4 }}>
{m.extra ? m.extra : m.badge ? (
<span style={{
fontSize: 10, padding: '1px 6px',
background: m.badge.bg, color: m.badge.color,
borderRadius: 3, fontWeight: 600, whiteSpace: 'nowrap',
}}>
{m.badge.text}
</span>
) : m.sub ? (
<span style={{ fontSize: 12, color: '#87867f', whiteSpace: 'nowrap' }}>{m.sub}</span>
) : null}
</div>
</div>
{/* Divider — figma: 獨立元素 width:0.5px height:36px */}
{i < metrics.length - 1 && (
<div style={{ width: '0.5px', height: 36, background: '#e0ddd4', alignSelf: 'center', flexShrink: 0 }} />
)}
</React.Fragment>
))}
{/* 自動修復率 */}
<div style={{ flex: 1, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '8px 12px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('autoRemediationRate')}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginTop: 2 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: '#22C55E' }}>{autoRemediationRate}</span>
{autoRemediationPct > 0 && <span style={{ fontSize: 10, fontWeight: 700, color: '#22C55E' }}>5%</span>}
</div>
<div style={{ height: 3, borderRadius: 2, background: '#ebe8df', marginTop: 4, overflow: 'hidden' }}>
<div style={{ width: `${autoRemediationPct}%`, height: '100%', borderRadius: 2, background: 'linear-gradient(90deg,#22C55E,#4ade80)' }} />
</div>
</div>
{/* 待審批 */}
<div style={{ flex: 1, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '8px 12px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('pendingApprovals')}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginTop: 2 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: hasPendingApprovals ? '#F59E0B' : '#141413' }}>{pendingApprovals ?? '--'}</span>
{hasPendingApprovals && <span style={{ fontSize: 9, color: '#F59E0B' }}>{tDashboard('awaitingConfirm')}</span>}
</div>
</div>
{/* 本週操作 */}
<div style={{ flex: 1, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '8px 12px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('todayIncidents')}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#141413', marginTop: 2 }}>{weeklyOps != null ? weeklyOps.toLocaleString() : '--'}</div>
</div>
</div>