From 72af10b43b2f23574c996ff5fb49ae8da8086eda Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 20 May 2026 14:00:55 +0800 Subject: [PATCH] fix(web): align homepage evidence with live data --- apps/web/messages/en.json | 3 + apps/web/messages/zh-TW.json | 3 + apps/web/src/app/[locale]/page.tsx | 419 +++++++++++------------------ 3 files changed, 167 insertions(+), 258 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8f27757d..25e4f5b2 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -204,6 +204,9 @@ "alertBadgeZero": "0 alerts", "awaitingConfirm": "Awaiting Confirmation", "viewAllAlerts": "View All Alerts", + "showingLatestIncidents": "Showing latest {shown} of {total}; open Alerts for the full list", + "relatedIncidents": "Related Incidents", + "noRelatedIncidents": "No related incidents", "viewAllAuth": "View All Authorizations", "viewAllReport": "View Full Report", "aiModelStatus": "AI Model Status", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index bfbc717d..71d57ea4 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -205,6 +205,9 @@ "alertBadgeZero": "0 告警", "awaitingConfirm": "等待確認", "viewAllAlerts": "查看全部告警", + "showingLatestIncidents": "顯示最新 {shown} / 共 {total} 筆;完整列表在告警頁", + "relatedIncidents": "相關事件", + "noRelatedIncidents": "無相關事件", "viewAllAuth": "查看全部授權", "viewAllReport": "查看完整報表", "aiModelStatus": "AI 模型狀態", diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 0620a8db..ddefa2bf 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -17,17 +17,13 @@ import React from 'react' import { useTranslations } from 'next-intl' import { useState, useEffect } from 'react' -import { useGlobalPulseMetrics } from '@/hooks/useGlobalPulseMetrics' import { useIncidents } from '@/hooks/useIncidents' -import { useHosts, useDashboardStore } from '@/stores/dashboard.store' +import { useHosts, useDashboardStore, type Host } from '@/stores/dashboard.store' 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' import { PulseSkeleton, CardSkeleton } from '@/components/shared/pulse-skeleton' -import { ServiceTopology } from '@/components/topology' -import { BarChart3, Flame, Telescope, FlaskConical, Activity, GitBranch } from 'lucide-react' import { DispositionMini } from '@/components/shared/disposition-mini' import { RecentActivity } from '@/components/shared/recent-activity' import { PendingApprovalsCard } from '@/components/shared/pending-approvals-card' @@ -38,6 +34,7 @@ import type { AwoooPStatusChain } from '@/components/awooop/status-chain' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' const STATUS_CHAIN_PREFETCH_LIMIT = 25 +const HOMEPAGE_INCIDENT_LIMIT = STATUS_CHAIN_PREFETCH_LIMIT // ============================================================================= // Tab 2: 告警 & 授權 (串接真實 API) @@ -45,7 +42,6 @@ const STATUS_CHAIN_PREFETCH_LIMIT = 25 function AlertsAndApprovalsTab() { const t = useTranslations('dashboard') - const tc = useTranslations('common') const [alerts, setAlerts] = useState([]) const [approvals, setApprovals] = useState([]) const [loading, setLoading] = useState(true) @@ -361,42 +357,6 @@ function DispositionTab() { ) } -// ============================================================================= -// Types -// ============================================================================= - -interface MetricItem { - label: string - value: string | number - sub?: string - badge?: { text: string; color: string; bg: string } - valueColor?: string - trend?: { text: string; color: string } // figma: value-row 右側趨勢箭頭 - extra?: React.ReactNode // figma: metric-extra 第三行 -} - -// ============================================================================= -// 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 ( - - - - ) -} - // ============================================================================= // Monitoring Tools Component — figma-v2 style // Left 3px accent bar + clickable links + meta row @@ -423,16 +383,6 @@ const TOOL_ACCENT_COLOR: Record = { Gitea: '#22C55E', } -// 圖示 Lucide icon (feedback_no_emoji_use_icons.md) -const TOOL_ICON: Record = { - Grafana: , - Prometheus: , - Sentry: , - Langfuse: , - SigNoz: , - Gitea: , -} - function MonitoringTools() { const tDash = useTranslations('dashboard') const tCommon = useTranslations('common') @@ -497,94 +447,53 @@ function MonitoringTools() { // Main Page // ============================================================================= -// ============================================================================= -// Static Host Service Catalog -// 定義每台主機完整服務清單(API 只回傳部分,此處補全靜態資訊) -// ============================================================================= - -const HOST_CATALOG: Record = { - '192.168.0.110': { - services: [ - { name: 'Harbor', healthy: false, port: 5000, description: 'Container Registry' }, - { name: 'Gitea', healthy: false, port: 3001, description: 'Git · CI/CD' }, - { name: 'Sentry', healthy: false, port: 9000, description: 'Error Tracking' }, - { name: 'Langfuse', healthy: false, port: 3100, description: 'LLM Tracing' }, - { name: 'Grafana', healthy: false, port: 3002, description: '監控面板' }, - { name: 'Prometheus', healthy: false, port: 9090, description: '告警規則' }, - ], - }, - '192.168.0.112': { - services: [ - { name: 'Scanner API', healthy: false, port: 8080, description: '漏洞掃描' }, - ], - }, - '192.168.0.120': { - isK3s: true, - role: 'Control Plane #1', - services: [ - { name: 'K3s API', healthy: false, port: 6443, description: 'kubectl', isK3s: true }, - { name: 'Traefik', healthy: false, description: 'Ingress', isK3s: true }, - { name: 'awoooi-prod', healthy: false, description: 'Namespace', isK3s: true }, - { name: 'keepalived', healthy: false, description: 'VIP MASTER', isK3s: true }, - ], - }, - '192.168.0.121': { - isK3s: true, - role: 'Control Plane #2 (HA)', - services: [ - { name: 'K3s API', healthy: false, port: 6443, description: 'kubectl', isK3s: true }, - { name: 'API', healthy: false, port: 32334, description: 'NodePort', isK3s: true }, - { name: 'Web', healthy: false, port: 32335, description: 'NodePort', isK3s: true }, - { name: 'keepalived', healthy: false, description: 'VIP BACKUP', isK3s: true }, - ], - }, - '192.168.0.188': { - services: [ - { name: 'Nginx', healthy: false, port: 443, description: 'Reverse Proxy' }, - { name: 'PostgreSQL', healthy: false, port: 5432, description: 'K3s Datastore' }, - { name: 'Redis', healthy: false, port: 6380, description: 'Cache' }, - { name: 'Ollama', healthy: false, port: 11434, description: 'LLM' }, - { name: 'OpenClaw', healthy: false, port: 8088, description: 'AI Agent' }, - { name: 'SigNoz', healthy: false, port: 3301, description: 'APM · OTEL' }, - ], - }, +function isHealthyService(status: string | null | undefined) { + return status === 'up' || status === 'healthy' } -/** 合併 API 動態健康狀態 + 靜態服務清單 */ -function buildHostInfo( - ip: string, - hostname: string, - cpuPct: number | null, - ramPct: number | null, - dynamicServices: { name: string; status: string }[], -): HostInfo { - const catalog = HOST_CATALOG[ip] - const services: HostService[] = catalog - ? catalog.services.map(s => { - const dyn = dynamicServices.find(d => d.name.toLowerCase() === s.name.toLowerCase()) - return { - ...s, - healthy: dyn ? (dyn.status === 'up' || dyn.status === 'healthy') : false, - } - }) - : dynamicServices.map(s => ({ - name: s.name, - healthy: s.status === 'up' || s.status === 'healthy', - })) - return { - hostname, - ip, - cpuPct, - ramPct, - services, - isK3s: catalog?.isK3s, - role: catalog?.role, - } +function metricColor(value: number | null | undefined) { + if (value == null) return '#e0ddd4' + if (value >= 90) return '#cc2200' + if (value >= 70) return '#F59E0B' + return '#22C55E' +} + +function metricText(value: number | null | undefined) { + return value == null ? '--' : `${Math.round(value)}%` +} + +function roleLabelKey(role: string | null | undefined) { + if (role === 'security') return 'groupSecurity' + if (role === 'k3s') return 'groupK3s' + if (role === 'ai_web') return 'groupAiData' + return 'groupInfra' +} + +function roleAccent(role: string | null | undefined) { + if (role === 'security') return { border: 'rgba(204,34,0,0.25)', bg: 'rgba(204,34,0,0.02)', accent: '#cc2200' } + if (role === 'k3s') return { border: 'rgba(168,85,247,0.25)', bg: 'rgba(168,85,247,0.02)', accent: '#A855F7' } + if (role === 'ai_web') return { border: 'rgba(249,115,22,0.25)', bg: 'rgba(249,115,22,0.02)', accent: '#F97316' } + return { border: 'rgba(59,130,246,0.2)', bg: 'rgba(59,130,246,0.01)', accent: '#3B82F6' } +} + +function incidentMatchesHost(incident: { affected_services?: string[] }, host: Host) { + const terms = [ + host.ip, + host.ip.split('.').pop() ?? '', + host.name, + ...host.services.map(service => service.name), + ] + .filter(Boolean) + .map(term => term.toLowerCase()) + + return incident.affected_services?.some(serviceName => { + const normalized = serviceName.toLowerCase() + return terms.some(term => normalized.includes(term) || term.includes(normalized)) + }) ?? false } export default function Home({ params }: { params: { locale: string } }) { const tDashboard = useTranslations('dashboard') - const tCommon = useTranslations('common') const tTopo = useTranslations('topology') const locale = params.locale const hosts = useHosts() @@ -593,12 +502,6 @@ export default function Home({ params }: { params: { locale: string } }) { 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, - }) - // Real incidents const { incidents, @@ -672,9 +575,6 @@ export default function Home({ params }: { params: { locale: string } }) { return { healthyServices: healthy, totalServices: total } })() - // P0 count - const p0Count = incidents?.filter(i => i.severity === 'P0').length ?? 0 - // 2026-04-07 Claude Code: Sprint 4 E2 — 從 disposition API 取得真實自動化率 const [dispositionRate, setDispositionRate] = useState<{ auto_rate: number; total: number } | null>(null) useEffect(() => { @@ -691,9 +591,7 @@ export default function Home({ params }: { params: { locale: string } }) { if (dispositionRate && dispositionRate.total > 0) { return `${Math.round(dispositionRate.auto_rate * 100)}%` } - if (!incidents?.length) return '--' - const resolved = incidents.filter(i => i.status === 'resolved' || i.status === 'closed').length - return `${((resolved / incidents.length) * 100).toFixed(0)}%` + return '--' })() // 自動處置率數值 (for progress bar) @@ -701,71 +599,43 @@ export default function Home({ params }: { params: { locale: string } }) { if (dispositionRate && dispositionRate.total > 0) { return Math.round(dispositionRate.auto_rate * 100) } - if (!incidents?.length) return 0 - const resolved = incidents.filter(i => i.status === 'resolved' || i.status === 'closed').length - return Math.round((resolved / incidents.length) * 100) + return 0 })() - // MTTR 均值 + 趨勢(前半 vs 後半比較) - const { mttrAvg, mttrTrend } = (() => { - if (!incidents?.length) return { mttrAvg: '--', mttrTrend: undefined } - const resolved = incidents.filter(i => i.updated_at && (i.status === 'resolved' || i.status === 'closed')) - if (!resolved.length) return { mttrAvg: '--', mttrTrend: undefined } - const durs = resolved.map(i => new Date(i.updated_at).getTime() - new Date(i.created_at).getTime()) - const avgMs = durs.reduce((a, b) => a + b, 0) / durs.length - const mins = Math.round(avgMs / 60000) - const avgStr = mins < 60 ? `${mins}m` : `${(mins / 60).toFixed(1)}h` - // 趨勢:比較前半與後半(需至少4筆) - let trend: { text: string; color: string } | undefined - if (durs.length >= 4) { - const half = Math.floor(durs.length / 2) - const older = durs.slice(0, half).reduce((a, b) => a + b, 0) / half - const newer = durs.slice(half).reduce((a, b) => a + b, 0) / (durs.length - half) - const diffMins = Math.round((newer - older) / 60000) - if (Math.abs(diffMins) >= 1) { - trend = diffMins < 0 - ? { text: `↓${Math.abs(diffMins)}m`, color: '#22C55E' } - : { text: `↑${diffMins}m`, color: '#d97757' } - } - } - return { mttrAvg: avgStr, mttrTrend: trend } - })() - - // 今日事件 sparkline: 過去 6 小時每小時事件數 (真實數據) - const todaySparkValues = (() => { - if (!incidents?.length) return null - const now = Date.now() - const buckets = Array.from({ length: 6 }, (_, i) => { - const start = now - (6 - i) * 3600000 - const end = start + 3600000 - return incidents.filter(inc => { - const t = new Date(inc.created_at).getTime() - return t >= start && t < end - }).length - }) - return buckets.some(v => v > 0) ? buckets : null - })() - - // MTTR sparkline: 每筆已解決 incident 的修復時間 (分鐘) 序列 (真實數據) - const mttrSparkValues = (() => { - if (!incidents?.length) return null - const resolved = incidents - .filter(i => i.updated_at && (i.status === 'resolved' || i.status === 'closed')) - .map(i => Math.round((new Date(i.updated_at).getTime() - new Date(i.created_at).getTime()) / 60000)) - .filter(m => m > 0) - return resolved.length >= 2 ? resolved.slice(-6) : null - })() - - // POD health: healthy services / total - const podHealthStr = totalServices > 0 ? `${healthyServices}/${totalServices}` : '--' - const podAllRunning = totalServices > 0 && healthyServices === totalServices - // ── 5 KPI Cards (Sprint 5R 設計稿批准版) ──────────────────────────────────── const hasPendingApprovals = pendingApprovals !== null && pendingApprovals !== undefined && pendingApprovals > 0 const incidentCount = incidents?.length ?? 0 const p1Count = incidents?.filter(i => i.severity === 'P1').length ?? 0 const p2Count = incidents?.filter(i => i.severity === 'P2').length ?? 0 + const visibleIncidents = incidents?.slice(0, HOMEPAGE_INCIDENT_LIMIT) ?? [] + const hiddenIncidentCount = Math.max(incidentCount - visibleIncidents.length, 0) + const liveTopologyGroups = Object.values( + hosts.reduce>((acc, host) => { + const role = host.role || 'devops' + const current = acc[role] ?? { role, hosts: [], serviceCount: 0, healthyServices: 0, serviceNames: [] } + current.hosts.push(host) + current.serviceCount += host.services.length + current.healthyServices += host.services.filter(service => isHealthyService(service.status)).length + current.serviceNames.push(...host.services.map(service => service.name)) + acc[role] = current + return acc + }, {}) + ) + .map(group => ({ + ...group, + serviceNames: Array.from(new Set(group.serviceNames)).slice(0, 5), + })) + .sort((a, b) => { + const order = ['devops', 'security', 'k3s', 'ai_web'] + return order.indexOf(a.role) - order.indexOf(b.role) + }) // 近 24h 操作數。首頁 KPI 必須顯示窗口值,避免把 total_executions 誤讀成今日事件。 const [auditStats, setAuditStats] = useState<{ last_24h_count?: number; total_executions?: number } | null>(null) @@ -798,7 +668,7 @@ export default function Home({ params }: { params: { locale: string } }) { // Sprint 5: 從 URL 讀取當前 Tab const [activeTabId, setActiveTabId] = useState('overview') const [infraView, setInfraView] = useState<'host' | 'topo'>('topo') - const [selectedHost, setSelectedHost] = useState(null) + const [selectedHost, setSelectedHost] = useState(null) // I1 修正: popstate 取代 100ms 輪詢 useEffect(() => { @@ -922,7 +792,12 @@ export default function Home({ params }: { params: { locale: string } }) { {incidents?.length} )} - 查看全部告警 → + + {tDashboard('viewAllAlerts')} +
@@ -937,7 +812,7 @@ export default function Home({ params }: { params: { locale: string } }) {
) : (
- {incidents?.map((incident) => ( + {visibleIncidents.map((incident) => ( ))} + {hiddenIncidentCount > 0 && ( + + {tDashboard('showingLatestIncidents', { shown: visibleIncidents.length, total: incidentCount })} + + )}
)} @@ -1049,52 +942,61 @@ export default function Home({ params }: { params: { locale: string } }) { {/* 拓撲群組 2×2 (設計稿 L514-519) */} {infraView === 'topo' && (
- {[ - { name: `${tTopo('groupInfra')} (.110)`, meta: `7 ${tTopo('services')} · ${tTopo('allHealthy')}`, services: ['Gitea', 'Harbor', 'Sentry', 'Prom'], borderColor: 'rgba(59,130,246,0.2)', bg: 'rgba(59,130,246,0.01)' }, - { name: `${tTopo('groupAiData')} (.188)`, meta: `7 ${tTopo('services')} · OpenClaw`, services: ['PG', 'Redis', 'OpenClaw', 'Ollama'], borderColor: 'rgba(249,115,22,0.25)', bg: 'rgba(249,115,22,0.01)' }, - { name: tTopo('groupK3s'), meta: `5 ${tTopo('services')} · ${incidentCount > 0 ? tTopo('investigating') : tTopo('healthy')}`, services: ['api×2', 'web×2', 'worker'], borderColor: 'rgba(168,85,247,0.25)', bg: 'rgba(168,85,247,0.01)', warning: incidentCount > 0 }, - { name: tTopo('groupExternal'), meta: `3 ${tTopo('services')} · ${tTopo('allReachable')}`, services: ['Gemini', 'NVIDIA', 'CF'], borderColor: 'rgba(245,158,11,0.2)', bg: 'rgba(245,158,11,0.01)' }, - ].map(g => ( -
-
{g.name}
-
{g.meta}
-
- {g.services.map(s => ( - - - {s} - - ))} -
+ {liveTopologyGroups.length === 0 ? ( +
+ {tDashboard('waitingHostData')}
- ))} + ) : liveTopologyGroups.map(g => { + const accent = roleAccent(g.role) + const isHealthy = g.serviceCount > 0 && g.healthyServices === g.serviceCount + const statusLabel = isHealthy ? tTopo('healthy') : tTopo('warning') + return ( +
+
+ +
{tTopo(roleLabelKey(g.role) as never)}
+
+
+ {g.hosts.length} {tDashboard('hostsLabel')} · {g.healthyServices}/{g.serviceCount} {tTopo('services')} · {statusLabel} +
+
+ {g.serviceNames.map(s => ( + + + {s} + + ))} +
+
+ ) + })}
)} {/* 主機網格 2×2 (設計稿 L522-527) */} {infraView === 'host' && (
- {[ - { name: tTopo('hostDevops'), ip: '192.168.0.110', cpu: 35, ram: 55 }, - { name: tTopo('hostAiData'), ip: '192.168.0.188', cpu: 67, ram: 72 }, - { name: tTopo('hostK3sMaster'), ip: '192.168.0.120', cpu: 45, ram: 60 }, - { name: tTopo('hostK3sWorker'), ip: '192.168.0.121', cpu: null as number | null, ram: null as number | null }, - ].map(h => { - const apiHost = hosts.find(ah => ah.ip === h.ip) - const cpu = apiHost?.metrics?.cpu_percent ?? h.cpu - const ram = apiHost?.metrics?.memory_percent ?? h.ram + {hosts.length === 0 ? ( +
+ {tDashboard('waitingHostData')} +
+ ) : hosts.map(h => { + const cpu = h.metrics?.cpu_percent ?? null + const ram = h.metrics?.memory_percent ?? null const isSelected = selectedHost?.ip === h.ip + const serviceTotal = h.services.length + const serviceHealthy = h.services.filter(service => isHealthyService(service.status)).length return (
setSelectedHost(isSelected ? null : { ...h, cpu, ram, services: apiHost?.services ?? [], status: apiHost?.status ?? 'unknown', role: apiHost?.role })} + onClick={() => setSelectedHost(isSelected ? null : h)} style={{ border: `0.5px solid ${isSelected ? '#4A90D9' : '#e0ddd4'}`, borderRadius: 8, padding: '8px 10px', @@ -1104,14 +1006,17 @@ export default function Home({ params }: { params: { locale: string } }) { >
{h.name}
{h.ip}
+
+ {serviceHealthy}/{serviceTotal} {tTopo('services')} · {h.status} +
- {['CPU', 'RAM'].map((label, idx) => { + {[tTopo('cpu'), tTopo('ram')].map((label, idx) => { const val = idx === 0 ? cpu : ram - const color = val != null ? (val > 60 ? '#F59E0B' : '#22C55E') : '#e0ddd4' + const color = metricColor(val) return (
- {label}{val != null ? `${val}%` : '--'} + {label}{metricText(val)}
@@ -1129,9 +1034,7 @@ export default function Home({ params }: { params: { locale: string } }) { {/* 主機詳情抽屜 — 點擊主機卡滑入 */} {infraView === 'host' && selectedHost && (() => { const sh = selectedHost - const relatedIncidents = incidents.filter(inc => - inc.affected_services?.some(s => s.includes(sh.ip)) - ).slice(0, 3) + const relatedIncidents = incidents.filter(inc => incidentMatchesHost(inc, sh)).slice(0, 3) return (
- {[{ label: 'CPU', val: sh.cpu }, { label: 'RAM', val: sh.ram }].map(m => { - const color = m.val != null ? (m.val > 80 ? '#cc2200' : m.val > 60 ? '#F59E0B' : '#22C55E') : '#b0ad9f' + {[{ label: tTopo('cpu'), val: sh.metrics?.cpu_percent ?? null }, { label: tTopo('ram'), val: sh.metrics?.memory_percent ?? null }].map(m => { + const color = metricColor(m.val) return (
{m.label}
-
{m.val != null ? `${m.val}%` : '--'}
+
{metricText(m.val)}
) })}
{/* 服務清單 */} -
服務
+
{tTopo('services')}
{sh.services.length === 0 ? (
--
- ) : sh.services.map((svc: any, i: number) => ( + ) : sh.services.map((svc, i) => (
-
+
{svc.name} {svc.port && :{svc.port}} {svc.latency_ms != null && {svc.latency_ms}ms} @@ -1187,14 +1090,14 @@ export default function Home({ params }: { params: { locale: string } }) { {/* 右:相關事件 */}
-
相關事件
+
{tDashboard('relatedIncidents')}
{relatedIncidents.length === 0 ? ( -
無相關事件
- ) : relatedIncidents.map((inc: any, i: number) => ( +
{tDashboard('noRelatedIncidents')}
+ ) : relatedIncidents.map((inc, i) => (
{inc.severity}
{inc.incident_id}
-
{inc.status} · {inc.affected_services.slice(0,2).join(', ')}
+
{inc.status} · {inc.affected_services?.slice(0,2).join(', ') ?? '--'}
))}