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:
@@ -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 有橘色 ↑N,extra 有折線
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user