feat(web): Sprint 5 Phase 1.2 — 首頁 4-Tab 結構 (全部串接真實 API)

Tab 1 戰情總覽: 保留現有首頁所有元素 (MetricsStrip + IncidentCard + OpenClaw + HostGrid + MonitoringTools)
Tab 2 告警 & 授權: 串接 /api/v1/incidents + /api/v1/approvals (真實數據)
Tab 3 活動串流: 串接 SSE /api/v1/dashboard/stream (EventSource 即時)
Tab 4 處置統計: 串接 /api/v1/stats/disposition (Sprint 4 API)

零假數據: 所有 Tab 無資料時顯示空狀態,不用 Mock

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-08 18:17:10 +08:00
parent 46ca2eadc3
commit 4f2f9e176f

View File

@@ -24,9 +24,192 @@ import { IncidentCard } from '@/components/incident'
import { OpenClawPanel } from '@/components/ai/openclaw-panel'
import { HostGrid, type HostInfo, type HostService } from '@/components/infra/host-grid'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
// =============================================================================
// Tab 2: 告警 & 授權 (串接真實 API)
// =============================================================================
function AlertsAndApprovalsTab() {
const t = useTranslations('dashboard')
const tc = useTranslations('common')
const [alerts, setAlerts] = useState<any[]>([])
const [approvals, setApprovals] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/api/v1/incidents`).then(r => r.json()).catch(() => ({ incidents: [] })),
fetch(`${API_BASE}/api/v1/approvals?status=pending`).then(r => r.json()).catch(() => []),
]).then(([incData, apprData]) => {
setAlerts(incData.incidents ?? incData ?? [])
setApprovals(Array.isArray(apprData) ? apprData : apprData.approvals ?? [])
}).finally(() => setLoading(false))
}, [])
if (loading) return <div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>{tc('loading')}</div>
return (
<div style={{ display: 'flex', gap: 20, padding: '16px 20px', flex: 1, overflow: 'hidden' }}>
{/* 左: 告警列表 */}
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: '#141413' }}> ({alerts.length})</div>
{alerts.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#87867f' }}></div>
) : alerts.map((a: any, i: number) => (
<div key={a.incident_id || i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '10px 14px', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 3, background: 'rgba(204,34,0,0.1)', color: '#cc2200' }}>{a.severity || 'P2'}</span>
<span style={{ fontSize: 13, fontWeight: 600 }}>{a.title || a.incident_id}</span>
</div>
<div style={{ fontSize: 11, color: '#555550', marginTop: 4 }}>{a.affected_services?.join(', ') || '--'} · {a.status || '--'}</div>
</div>
))}
</div>
{/* 右: 授權待批准 */}
<div style={{ width: 400, flexShrink: 0, overflowY: 'auto' }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: '#141413' }}> ({approvals.length})</div>
{approvals.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#87867f' }}></div>
) : approvals.map((ap: any, i: number) => (
<div key={ap.id || i} style={{ background: '#faf9f3', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '10px 12px', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#cc2200' }}>{ap.action || ap.title || '--'}</div>
<div style={{ fontSize: 10, color: '#555550', marginTop: 2, fontFamily: "'JetBrains Mono', monospace" }}>{ap.resource || '--'}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
<button style={{ flex: 1, padding: '5px 0', border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer', background: '#22C55E', color: '#fff' }}></button>
<button style={{ flex: 1, padding: '5px 0', border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: '#fff', color: '#87867f' }}></button>
</div>
</div>
))}
</div>
</div>
)
}
// =============================================================================
// Tab 3: 活動串流 (串接真實 SSE)
// =============================================================================
function ActivityStreamTab() {
const tc = useTranslations('common')
const [events, setEvents] = useState<any[]>([])
const [connected, setConnected] = useState(false)
useEffect(() => {
const es = new EventSource(`${API_BASE}/api/v1/dashboard/stream`)
es.onopen = () => setConnected(true)
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
setEvents(prev => [{ ...data, _time: new Date().toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }, ...prev].slice(0, 50))
} catch {}
}
es.onerror = () => setConnected(false)
return () => es.close()
}, [])
return (
<div style={{ padding: '16px 20px', flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: connected ? '#22C55E' : '#cc2200' }} />
<span style={{ fontSize: 14, fontWeight: 700 }}></span>
<span style={{ fontSize: 11, color: '#87867f' }}>{connected ? 'SSE 連線中' : '連線中斷'} · {events.length} </span>
</div>
{events.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>...</div>
) : events.map((ev, i) => (
<div key={i} style={{ display: 'flex', gap: 8, padding: '6px 0', borderBottom: '0.5px solid #f0ede5', fontSize: 12 }}>
<span style={{ fontSize: 10, color: '#87867f', fontFamily: "'JetBrains Mono', monospace", width: 60, flexShrink: 0 }}>{ev._time}</span>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: ev.type === 'HOST_UPDATE' ? '#22C55E' : '#4A90D9', marginTop: 5, flexShrink: 0 }} />
<span style={{ flex: 1, lineHeight: 1.4 }}>
<b>{ev.type || 'EVENT'}</b>
{ev.data?.overall_status && ` · 狀態: ${ev.data.overall_status}`}
{ev.data?.hosts && ` · ${ev.data.hosts.length} 主機`}
</span>
</div>
))}
</div>
)
}
// =============================================================================
// Tab 4: 處置統計 (串接真實 /stats/disposition)
// =============================================================================
function DispositionTab() {
const tc = useTranslations('common')
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`${API_BASE}/api/v1/stats/disposition`)
.then(r => r.json())
.then(d => setData(d))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (loading) return <div style={{ padding: 32, textAlign: 'center', color: '#87867f' }}>{tc('loading')}</div>
const s = data?.summary
if (!s || s.total === 0) return (
<div style={{ padding: 48, textAlign: 'center', color: '#87867f' }}>
<div style={{ fontSize: 32, marginBottom: 8, opacity: 0.3 }}>📊</div>
<div></div>
</div>
)
const autoRate = Math.round(s.auto_rate * 100)
return (
<div style={{ padding: '16px 20px', flex: 1, overflowY: 'auto' }}>
{/* KPI 3 卡 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 16 }}>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}></div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#4A90D9', marginTop: 4 }}>{s.total}</div>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}></div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#22C55E', marginTop: 4 }}>{autoRate}%</div>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', fontWeight: 500 }}></div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#F59E0B', marginTop: 4 }}>{Math.round(s.human_rate * 100)}%</div>
</div>
</div>
{/* 四大計數 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 16 }}>
{[
{ label: '自動修復', count: s.auto_repair, color: '#22C55E', bg: 'rgba(34,197,94,0.06)', border: 'rgba(34,197,94,0.25)' },
{ label: '人工審核', count: s.human_approved, color: '#F59E0B', bg: 'rgba(249,115,22,0.06)', border: 'rgba(249,115,22,0.25)' },
{ label: '手動處理', count: s.manual_resolved, color: '#A855F7', bg: 'rgba(168,85,247,0.06)', border: 'rgba(168,85,247,0.25)' },
{ label: '冷啟動', count: s.cold_start_trust, color: '#4A90D9', bg: 'rgba(59,130,246,0.06)', border: 'rgba(59,130,246,0.25)' },
].map(item => (
<div key={item.label} style={{ background: item.bg, border: `0.5px solid ${item.border}`, borderRadius: 10, padding: 14, textAlign: 'center' }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: item.color, marginBottom: 6 }}>{item.label}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: item.color }}>{item.count}</div>
</div>
))}
</div>
{/* 堆疊分佈條 */}
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#87867f', marginBottom: 8 }}></div>
<div style={{ display: 'flex', height: 12, borderRadius: 6, overflow: 'hidden' }}>
{s.total > 0 && <>
<div style={{ width: `${(s.auto_repair/s.total)*100}%`, background: '#22C55E' }} />
<div style={{ width: `${(s.cold_start_trust/s.total)*100}%`, background: '#4A90D9' }} />
<div style={{ width: `${(s.human_approved/s.total)*100}%`, background: '#F59E0B' }} />
<div style={{ width: `${(s.manual_resolved/s.total)*100}%`, background: '#A855F7' }} />
</>}
</div>
</div>
</div>
)
}
// =============================================================================
// Types
// =============================================================================
@@ -541,9 +724,30 @@ export default function Home({ params }: { params: { locale: string } }) {
},
]
// Sprint 5: 4 Tab 配置 (統帥批准 2026-04-08)
const alertsCount = incidents?.length ?? 0
const tabs: TabConfig[] = [
{ id: 'overview', label: '戰情總覽', content: null }, // Tab 1 用現有內容,下方直接渲染
{ id: 'alerts', label: '告警 & 授權', badge: alertsCount > 0 ? alertsCount : undefined, content: <AlertsAndApprovalsTab /> },
{ id: 'stream', label: '活動串流', content: <ActivityStreamTab /> },
{ id: 'disposition', label: '處置統計', content: <DispositionTab /> },
]
// 判斷目前 Tab
const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null
const currentTab = searchParams?.get('tab') || 'overview'
return (
<AppLayout locale={locale} showBackground={false} fullBleed>
{/* fullBleed: AppLayout 不加 p-6直接填滿 header 以下空間 */}
{/* Sprint 5: Tab Bar */}
<PageTabs
tabs={tabs.map(t => ({ ...t, content: t.id === 'overview' ? <></> : t.content }))}
defaultTab="overview"
syncWithUrl={true}
/>
{/* Tab 1 戰情總覽: 顯示現有首頁完整內容 (不動任何東西) */}
{(currentTab === 'overview' || !currentTab) && (
<div style={{
display: 'flex',
flexDirection: 'column',
@@ -810,6 +1014,7 @@ export default function Home({ params }: { params: { locale: string } }) {
</div>
</div>
</div>
)}
</AppLayout>
)
}