fix(web): align homepage evidence with live data
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 3m58s
CD Pipeline / build-and-deploy (push) Successful in 3m47s
CD Pipeline / post-deploy-checks (push) Successful in 1m53s

This commit is contained in:
Your Name
2026-05-20 14:00:55 +08:00
parent ef811c979b
commit 72af10b43b
3 changed files with 167 additions and 258 deletions

View File

@@ -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",

View File

@@ -205,6 +205,9 @@
"alertBadgeZero": "0 告警",
"awaitingConfirm": "等待確認",
"viewAllAlerts": "查看全部告警",
"showingLatestIncidents": "顯示最新 {shown} / 共 {total} 筆;完整列表在告警頁",
"relatedIncidents": "相關事件",
"noRelatedIncidents": "無相關事件",
"viewAllAuth": "查看全部授權",
"viewAllReport": "查看完整報表",
"aiModelStatus": "AI 模型狀態",

View File

@@ -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<any[]>([])
const [approvals, setApprovals] = useState<any[]>([])
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 (
<svg width={w} height={h} style={{ display: 'block', flexShrink: 0 }}>
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// =============================================================================
// Monitoring Tools Component — figma-v2 style
// Left 3px accent bar + clickable links + meta row
@@ -423,16 +383,6 @@ const TOOL_ACCENT_COLOR: Record<string, string> = {
Gitea: '#22C55E',
}
// 圖示 Lucide icon (feedback_no_emoji_use_icons.md)
const TOOL_ICON: Record<string, React.ReactNode> = {
Grafana: <BarChart3 size={16} />,
Prometheus: <Flame size={16} />,
Sentry: <Telescope size={16} />,
Langfuse: <FlaskConical size={16} />,
SigNoz: <Activity size={16} />,
Gitea: <GitBranch size={16} />,
}
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<string, { services: HostService[]; isK3s?: boolean; role?: string }> = {
'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<Record<string, {
role: string
hosts: Host[]
serviceCount: number
healthyServices: number
serviceNames: string[]
}>>((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<any>(null)
const [selectedHost, setSelectedHost] = useState<Host | null>(null)
// I1 修正: popstate 取代 100ms 輪詢
useEffect(() => {
@@ -922,7 +792,12 @@ export default function Home({ params }: { params: { locale: string } }) {
{incidents?.length}
</span>
)}
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}> </span>
<a
href={`/${locale}/alerts`}
style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500, textDecoration: 'none' }}
>
{tDashboard('viewAllAlerts')}
</a>
</div>
<div style={{ padding: '14px' }}>
@@ -937,7 +812,7 @@ export default function Home({ params }: { params: { locale: string } }) {
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{incidents?.map((incident) => (
{visibleIncidents.map((incident) => (
<IncidentCard
key={incident.incident_id}
incident={incident}
@@ -945,6 +820,24 @@ export default function Home({ params }: { params: { locale: string } }) {
statusChain={statusChains[incident.incident_id] ?? null}
/>
))}
{hiddenIncidentCount > 0 && (
<a
href={`/${locale}/alerts`}
style={{
padding: '9px 12px',
border: '0.5px solid #e0ddd4',
borderRadius: 8,
background: '#fbfaf6',
color: '#4A90D9',
fontSize: 12,
fontWeight: 600,
textAlign: 'center',
textDecoration: 'none',
}}
>
{tDashboard('showingLatestIncidents', { shown: visibleIncidents.length, total: incidentCount })}
</a>
)}
</div>
)}
</div>
@@ -1049,52 +942,61 @@ export default function Home({ params }: { params: { locale: string } }) {
{/* 拓撲群組 2×2 (設計稿 L514-519) */}
{infraView === 'topo' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, padding: 14 }}>
{[
{ 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 => (
<div key={g.name} style={{
border: `0.5px solid ${g.borderColor}`, borderRadius: 8, padding: '8px 10px',
background: g.bg, cursor: 'pointer', transition: 'all 0.12s',
...(g.warning ? { boxShadow: '0 0 8px rgba(245,158,11,0.15)' } : {}),
}}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 2 }}>{g.name}</div>
<div style={{ fontSize: 10, color: '#555550' }}>{g.meta}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2, marginTop: 4 }}>
{g.services.map(s => (
<span key={s} style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 7px', background: '#fff', border: '0.5px solid #e0ddd4',
borderRadius: 4, fontSize: 10,
}}>
<span style={{ width: 3, height: 3, borderRadius: '50%', background: s.includes('worker') && g.warning ? '#F59E0B' : '#22C55E' }} />
{s}
</span>
))}
</div>
{liveTopologyGroups.length === 0 ? (
<div style={{ gridColumn: '1 / -1', padding: 18, textAlign: 'center', color: '#87867f', fontSize: 12 }}>
{tDashboard('waitingHostData')}
</div>
))}
) : 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 (
<div key={g.role} style={{
border: `0.5px solid ${accent.border}`, borderRadius: 8, padding: '8px 10px',
background: accent.bg, transition: 'all 0.12s',
...(!isHealthy ? { boxShadow: '0 0 8px rgba(245,158,11,0.15)' } : {}),
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: isHealthy ? '#22C55E' : '#F59E0B', flexShrink: 0 }} />
<div style={{ fontSize: 12, fontWeight: 600 }}>{tTopo(roleLabelKey(g.role) as never)}</div>
</div>
<div style={{ fontSize: 10, color: '#555550' }}>
{g.hosts.length} {tDashboard('hostsLabel')} · {g.healthyServices}/{g.serviceCount} {tTopo('services')} · {statusLabel}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2, marginTop: 4 }}>
{g.serviceNames.map(s => (
<span key={s} style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 7px', background: '#fff', border: '0.5px solid #e0ddd4',
borderRadius: 4, fontSize: 10,
}}>
<span style={{ width: 3, height: 3, borderRadius: '50%', background: accent.accent }} />
{s}
</span>
))}
</div>
</div>
)
})}
</div>
)}
{/* 主機網格 2×2 (設計稿 L522-527) */}
{infraView === 'host' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, padding: 14 }}>
{[
{ 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 ? (
<div style={{ gridColumn: '1 / -1', padding: 18, textAlign: 'center', color: '#87867f', fontSize: 12 }}>
{tDashboard('waitingHostData')}
</div>
) : 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 (
<div
key={h.ip}
onClick={() => 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 } }) {
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 2 }}>{h.name}</div>
<div style={{ fontSize: 10, color: '#555550', fontFamily: "'JetBrains Mono', monospace" }}>{h.ip}</div>
<div style={{ fontSize: 9, color: '#87867f', marginTop: 3 }}>
{serviceHealthy}/{serviceTotal} {tTopo('services')} · {h.status}
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 5 }}>
{['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 (
<div key={label} style={{ flex: 1 }}>
<div style={{ fontSize: 7, color: '#87867f', display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
<span>{label}</span><span>{val != null ? `${val}%` : '--'}</span>
<span>{label}</span><span>{metricText(val)}</span>
</div>
<div style={{ height: 3, borderRadius: 2, background: '#ebe8df', overflow: 'hidden' }}>
<div style={{ width: val != null ? `${val}%` : '0%', height: '100%', borderRadius: 2, background: color }} />
@@ -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 (
<div style={{
margin: '0 14px 14px',
@@ -1161,23 +1064,23 @@ export default function Home({ params }: { params: { locale: string } }) {
{/* 左Metrics 大字 + 服務清單 */}
<div>
<div style={{ display: 'flex', gap: 12, marginBottom: 10 }}>
{[{ 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 (
<div key={m.label} style={{ flex: 1, border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '8px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 9, color: '#87867f', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>{m.label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color, marginTop: 2 }}>{m.val != null ? `${m.val}%` : '--'}</div>
<div style={{ fontSize: 24, fontWeight: 700, color, marginTop: 2 }}>{metricText(m.val)}</div>
</div>
)
})}
</div>
{/* 服務清單 */}
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#87867f', marginBottom: 5 }}></div>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#87867f', marginBottom: 5 }}>{tTopo('services')}</div>
{sh.services.length === 0 ? (
<div style={{ fontSize: 11, color: '#b0ad9f' }}>--</div>
) : sh.services.map((svc: any, i: number) => (
) : sh.services.map((svc, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0', borderBottom: '0.5px solid #f5f4ed', fontSize: 11 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: svc.status === 'up' ? '#22C55E' : '#cc2200', flexShrink: 0 }} />
<div style={{ width: 5, height: 5, borderRadius: '50%', background: isHealthyService(svc.status) ? '#22C55E' : '#cc2200', flexShrink: 0 }} />
<span style={{ flex: 1, color: '#141413' }}>{svc.name}</span>
{svc.port && <span style={{ color: '#87867f', fontFamily: "'JetBrains Mono', monospace", fontSize: 10 }}>:{svc.port}</span>}
{svc.latency_ms != null && <span style={{ color: '#87867f', fontSize: 10 }}>{svc.latency_ms}ms</span>}
@@ -1187,14 +1090,14 @@ export default function Home({ params }: { params: { locale: string } }) {
{/* 右:相關事件 */}
<div>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#87867f', marginBottom: 5 }}></div>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: '#87867f', marginBottom: 5 }}>{tDashboard('relatedIncidents')}</div>
{relatedIncidents.length === 0 ? (
<div style={{ fontSize: 11, color: '#b0ad9f' }}></div>
) : relatedIncidents.map((inc: any, i: number) => (
<div style={{ fontSize: 11, color: '#b0ad9f' }}>{tDashboard('noRelatedIncidents')}</div>
) : relatedIncidents.map((inc, i) => (
<div key={i} style={{ padding: '5px 8px', marginBottom: 4, borderRadius: 5, background: '#faf9f3', border: '0.5px solid #e0ddd4' }}>
<div style={{ fontSize: 10, fontWeight: 600, color: '#d97757', marginBottom: 2 }}>{inc.severity}</div>
<div style={{ fontSize: 11, color: '#141413', lineHeight: 1.3, fontFamily: "'JetBrains Mono', monospace" }}>{inc.incident_id}</div>
<div style={{ fontSize: 10, color: '#87867f', marginTop: 2 }}>{inc.status} · {inc.affected_services.slice(0,2).join(', ')}</div>
<div style={{ fontSize: 10, color: '#87867f', marginTop: 2 }}>{inc.status} · {inc.affected_services?.slice(0,2).join(', ') ?? '--'}</div>
</div>
))}
</div>