fix(web): align homepage evidence with live data
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -205,6 +205,9 @@
|
||||
"alertBadgeZero": "0 告警",
|
||||
"awaitingConfirm": "等待確認",
|
||||
"viewAllAlerts": "查看全部告警",
|
||||
"showingLatestIncidents": "顯示最新 {shown} / 共 {total} 筆;完整列表在告警頁",
|
||||
"relatedIncidents": "相關事件",
|
||||
"noRelatedIncidents": "無相關事件",
|
||||
"viewAllAuth": "查看全部授權",
|
||||
"viewAllReport": "查看完整報表",
|
||||
"aiModelStatus": "AI 模型狀態",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user