diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 20b0dcb4..9a69f1c8 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -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([]) + const [approvals, setApprovals] = useState([]) + 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
{tc('loading')}
+ + return ( +
+ {/* 左: 告警列表 */} +
+
告警事件 ({alerts.length})
+ {alerts.length === 0 ? ( +
目前無活躍告警
+ ) : alerts.map((a: any, i: number) => ( +
+
+ {a.severity || 'P2'} + {a.title || a.incident_id} +
+
{a.affected_services?.join(', ') || '--'} · {a.status || '--'}
+
+ ))} +
+ {/* 右: 授權待批准 */} +
+
待批准授權 ({approvals.length})
+ {approvals.length === 0 ? ( +
無待批准項目
+ ) : approvals.map((ap: any, i: number) => ( +
+
{ap.action || ap.title || '--'}
+
{ap.resource || '--'}
+
+ + +
+
+ ))} +
+
+ ) +} + +// ============================================================================= +// Tab 3: 活動串流 (串接真實 SSE) +// ============================================================================= + +function ActivityStreamTab() { + const tc = useTranslations('common') + const [events, setEvents] = useState([]) + 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 ( +
+
+
+ 系統活動串流 + {connected ? 'SSE 連線中' : '連線中斷'} · {events.length} 筆 +
+ {events.length === 0 ? ( +
等待即時事件...
+ ) : events.map((ev, i) => ( +
+ {ev._time} + + + {ev.type || 'EVENT'} + {ev.data?.overall_status && ` · 狀態: ${ev.data.overall_status}`} + {ev.data?.hosts && ` · ${ev.data.hosts.length} 主機`} + +
+ ))} +
+ ) +} + +// ============================================================================= +// Tab 4: 處置統計 (串接真實 /stats/disposition) +// ============================================================================= + +function DispositionTab() { + const tc = useTranslations('common') + const [data, setData] = useState(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
{tc('loading')}
+ + const s = data?.summary + if (!s || s.total === 0) return ( +
+
📊
+
目前無處置統計資料
+
+ ) + + const autoRate = Math.round(s.auto_rate * 100) + + return ( +
+ {/* KPI 3 卡 */} +
+
+
處置總次數
+
{s.total}
+
+
+
自動化率
+
{autoRate}%
+
+
+
人工介入率
+
{Math.round(s.human_rate * 100)}%
+
+
+ {/* 四大計數 */} +
+ {[ + { 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 => ( +
+
{item.label}
+
{item.count}
+
+ ))} +
+ {/* 堆疊分佈條 */} +
+
處置方式分佈
+
+ {s.total > 0 && <> +
+
+
+
+ } +
+
+
+ ) +} + // ============================================================================= // 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: }, + { id: 'stream', label: '活動串流', content: }, + { id: 'disposition', label: '處置統計', content: }, + ] + + // 判斷目前 Tab + const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null + const currentTab = searchParams?.get('tab') || 'overview' + return ( - {/* fullBleed: AppLayout 不加 p-6,直接填滿 header 以下空間 */} + {/* Sprint 5: Tab Bar */} + ({ ...t, content: t.id === 'overview' ? <> : t.content }))} + defaultTab="overview" + syncWithUrl={true} + /> + + {/* Tab 1 戰情總覽: 顯示現有首頁完整內容 (不動任何東西) */} + {(currentTab === 'overview' || !currentTab) && (
+ )} ) }