feat(infra): 完整監控工具 + 主機服務清單 + K3s Cluster 突顯
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 6m50s

監控工具 (6個):
- 加入 Grafana (110:3002), Sentry (110:9000), Langfuse (110:3100)
- 保留 Prometheus, SigNoz, Gitea

基礎架構:
- 靜態服務目錄 HOST_CATALOG:每台主機完整服務+Port+說明
- K3s Server #2 (121) 補靜態卡 (API 未回傳)
- K3s Cluster HA 獨立藍色區塊,☸ 標題 + VIP 資訊
- 所有服務含 Port 號與功能描述

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-03 00:36:59 +08:00
parent dbe71f82e3
commit 8bc086af58
3 changed files with 353 additions and 139 deletions

View File

@@ -1,12 +1,15 @@
"""
Monitoring Status API
=====================
探測各監控工具狀態Grafana / Prometheus / SigNoz / Gitea
探測所有可觀測性工具狀態:
Grafana / Prometheus / Sentry / Langfuse / SigNoz / Gitea
所有探測從後端發出,不暴露內網 IP 給前端。
Grafana: 110:3002 (Docker 3002→3000)
建立時間: 2026-04-03 (台北時區)
建立者: Claude Code
更新時間: 2026-04-03 加入 Grafana(3002) / Sentry / Langfuse
"""
import asyncio
@@ -21,70 +24,52 @@ logger = get_logger(__name__)
router = APIRouter(prefix="/monitoring", tags=["Monitoring"])
# =============================================================================
# Internal service endpoints (backend-only)
# =============================================================================
SERVICES = {
"grafana": {
"base": "http://192.168.0.188:3000",
"health": "/api/health",
"build": "/api/frontend/settings",
},
"prometheus": {
"base": "http://192.168.0.110:9090",
"health": "/-/healthy",
"build": "/api/v1/status/buildinfo",
"rules": "/api/v1/rules",
},
"signoz": {
"base": "http://192.168.0.188:3301",
"health": "/api/v1/health",
},
"gitea": {
"base": "http://192.168.0.110:3001",
"health": "/-/readiness",
},
}
TIMEOUT = 3.0
# =============================================================================
# Probes
# =============================================================================
async def _probe_grafana(client: httpx.AsyncClient) -> dict:
base = SERVICES["grafana"]["base"]
base = "http://192.168.0.110:3002"
try:
r = await client.get(f"{base}/api/health", timeout=TIMEOUT)
if r.status_code == 200:
data = r.json()
version = data.get("version", "")
# Try to get dashboard count
dash_r = await client.get(f"{base}/api/search?type=dash-db", timeout=TIMEOUT)
dash_count = len(dash_r.json()) if dash_r.status_code == 200 else None
version = data.get("version")
dash_r = await client.get(
f"{base}/api/search?type=dash-db",
headers={"X-Grafana-Org-Id": "1"},
timeout=TIMEOUT,
)
dash_count = len(dash_r.json()) if dash_r.status_code == 200 and isinstance(dash_r.json(), list) else None
return {
"name": "Grafana",
"status": "up",
"version": version,
"stats": f"面板 {dash_count}" if dash_count is not None else None,
"description": "監控面板 · 指標視覺化",
"stats": f"面板 {dash_count}" if dash_count is not None else "監控面板",
"description": "指標視覺化 · Dashboard",
}
except Exception as e:
logger.warning("grafana_probe_failed", error=str(e))
return {"name": "Grafana", "status": "down", "version": None, "stats": None, "description": "監控面板 · 指標視覺化"}
return {
"name": "Grafana", "status": "down", "version": None,
"stats": None, "description": "指標視覺化 · Dashboard",
}
async def _probe_prometheus(client: httpx.AsyncClient) -> dict:
base = SERVICES["prometheus"]["base"]
base = "http://192.168.0.110:9090"
try:
health_r = await client.get(f"{base}/-/healthy", timeout=TIMEOUT)
if health_r.status_code == 200:
# Get build info
build_r = await client.get(f"{base}/api/v1/status/buildinfo", timeout=TIMEOUT)
version = None
if build_r.status_code == 200:
version = build_r.json().get("data", {}).get("version")
# Get rules count
rules_r = await client.get(f"{base}/api/v1/rules", timeout=TIMEOUT)
rules_count = None
rules_count = 0
firing_count = 0
if rules_r.status_code == 200:
groups = rules_r.json().get("data", {}).get("groups", [])
@@ -93,62 +78,139 @@ async def _probe_prometheus(client: httpx.AsyncClient) -> dict:
1 for g in groups for r in g.get("rules", [])
if r.get("state") == "firing"
)
stats_parts = []
if rules_count is not None:
stats_parts.append(f"規則 {rules_count}")
stats_parts = [f"規則 {rules_count}"]
if firing_count > 0:
stats_parts.append(f"{firing_count} 觸發")
return {
"name": "Prometheus",
"status": "up",
"version": version,
"stats": " · ".join(stats_parts) if stats_parts else None,
"stats": " · ".join(stats_parts),
"description": "時序資料庫 · 告警規則",
"firing_count": firing_count,
}
except Exception as e:
logger.warning("prometheus_probe_failed", error=str(e))
return {"name": "Prometheus", "status": "down", "version": None, "stats": None, "description": "時序資料庫 · 告警規則", "firing_count": 0}
return {
"name": "Prometheus", "status": "down", "version": None,
"stats": None, "description": "時序資料庫 · 告警規則", "firing_count": 0,
}
async def _probe_sentry(client: httpx.AsyncClient) -> dict:
base = "http://192.168.0.110:9000"
try:
r = await client.get(f"{base}/_health/", timeout=TIMEOUT)
if r.status_code == 200 and r.text.strip() == "ok":
ver_r = await client.get(f"{base}/api/0/", timeout=TIMEOUT)
version = None
if ver_r.status_code == 200:
raw = ver_r.json().get("version")
if isinstance(raw, dict):
version = raw.get("version")
elif raw:
version = str(raw)
return {
"name": "Sentry",
"status": "up",
"version": version,
"stats": "Error Tracking · Issue",
"description": "錯誤追蹤 · Issue 管理",
}
except Exception as e:
logger.warning("sentry_probe_failed", error=str(e))
return {
"name": "Sentry", "status": "down", "version": None,
"stats": None, "description": "錯誤追蹤 · Issue 管理",
}
async def _probe_langfuse(client: httpx.AsyncClient) -> dict:
base = "http://192.168.0.110:3100"
try:
r = await client.get(f"{base}/api/public/health", timeout=TIMEOUT)
if r.status_code == 200:
data = r.json()
version = data.get("version")
return {
"name": "Langfuse",
"status": "up",
"version": version,
"stats": "LLM Tracing · AI 觀測",
"description": "LLM 追蹤 · AI 成本監控",
}
except Exception as e:
logger.warning("langfuse_probe_failed", error=str(e))
return {
"name": "Langfuse", "status": "down", "version": None,
"stats": None, "description": "LLM 追蹤 · AI 成本監控",
}
async def _probe_signoz(client: httpx.AsyncClient) -> dict:
base = SERVICES["signoz"]["base"]
base = "http://192.168.0.188:3301"
try:
r = await client.get(f"{base}/api/v1/health", timeout=TIMEOUT)
if r.status_code == 200:
return {"name": "SigNoz", "status": "up", "version": None, "stats": "APM · 追蹤 · 日誌", "description": "可觀測性平台"}
return {
"name": "SigNoz",
"status": "up",
"version": None,
"stats": "APM · Trace · Log",
"description": "可觀測性平台 · OTEL",
}
except Exception as e:
logger.warning("signoz_probe_failed", error=str(e))
# Fallback: try root
try:
r2 = await client.get(f"{base}/", timeout=TIMEOUT)
if r2.status_code in (200, 301, 302):
return {"name": "SigNoz", "status": "up", "version": None, "stats": "APM · 追蹤 · 日誌", "description": "可觀測性平台"}
return {
"name": "SigNoz", "status": "up", "version": None,
"stats": "APM · Trace · Log", "description": "可觀測性平台 · OTEL",
}
except Exception:
pass
return {"name": "SigNoz", "status": "down", "version": None, "stats": None, "description": "可觀測性平台"}
return {
"name": "SigNoz", "status": "down", "version": None,
"stats": None, "description": "可觀測性平台 · OTEL",
}
async def _probe_gitea(client: httpx.AsyncClient) -> dict:
base = SERVICES["gitea"]["base"]
base = "http://192.168.0.110:3001"
try:
r = await client.get(f"{base}/-/readiness", timeout=TIMEOUT)
if r.status_code == 200:
# Get version from API
ver_r = await client.get(f"{base}/api/v1/version", timeout=TIMEOUT)
version = None
if ver_r.status_code == 200:
version = ver_r.json().get("version")
return {"name": "Gitea", "status": "up", "version": version, "stats": "CI/CD · Git 倉庫", "description": "代碼倉庫 · Pipeline"}
return {
"name": "Gitea",
"status": "up",
"version": version,
"stats": "CI/CD · Git · Mirror",
"description": "代碼倉庫 · Pipeline",
}
except Exception as e:
logger.warning("gitea_probe_failed", error=str(e))
return {"name": "Gitea", "status": "down", "version": None, "stats": None, "description": "代碼倉庫 · Pipeline"}
return {
"name": "Gitea", "status": "down", "version": None,
"stats": None, "description": "代碼倉庫 · Pipeline",
}
# =============================================================================
# Router
# =============================================================================
@router.get("/status")
async def get_monitoring_status() -> dict:
"""
並行探測所有監控工具狀態
並行探測所有可觀測性工具狀態
工具清單: Grafana(3002) / Prometheus / Sentry / Langfuse / SigNoz / Gitea
注意: Loki 未安裝 (ADR: SigNoz 統一派)
Returns:
dict with tools list, each containing name/status/version/stats/description
@@ -157,18 +219,22 @@ async def get_monitoring_status() -> dict:
results = await asyncio.gather(
_probe_grafana(client),
_probe_prometheus(client),
_probe_sentry(client),
_probe_langfuse(client),
_probe_signoz(client),
_probe_gitea(client),
return_exceptions=True,
)
now = datetime.now(UTC).isoformat()
tools = []
for r in results:
if isinstance(r, Exception):
logger.error("monitoring_probe_exception", error=str(r))
continue
tools.append({**r, "checked_at": datetime.now(UTC).isoformat()})
tools.append({**r, "checked_at": now})
return {
"tools": tools,
"checked_at": datetime.now(UTC).isoformat(),
"checked_at": now,
}

View File

@@ -19,7 +19,7 @@ import { useIncidents } from '@/hooks/useIncidents'
import { useHosts, useDashboardStore } from '@/stores/dashboard.store'
import { IncidentCard } from '@/components/incident'
import { OpenClawPanel } from '@/components/ai/openclaw-panel'
import { HostGrid, type HostInfo } from '@/components/infra/host-grid'
import { HostGrid, type HostInfo, type HostService } from '@/components/infra/host-grid'
import { AppLayout } from '@/components/layout'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -153,6 +153,91 @@ 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' },
],
},
}
/** 合併 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,
}
}
export default function Home({ params }: { params: { locale: string } }) {
const tDashboard = useTranslations('dashboard')
const tCommon = useTranslations('common')
@@ -496,16 +581,17 @@ export default function Home({ params }: { params: { locale: string } }) {
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', flexShrink: 0 }} />
{tDashboard('infrastructure')}
</div>
<HostGrid hosts={hosts.map((h): HostInfo => ({
hostname: h.name,
ip: h.ip,
cpuPct: h.metrics?.cpu_percent ?? null,
ramPct: h.metrics?.memory_percent ?? null,
services: h.services.map(s => ({
name: s.name,
healthy: s.status === 'up' || s.status === 'healthy',
})),
}))} />
<HostGrid hosts={(() => {
const apiHosts = hosts.map(h =>
buildHostInfo(h.ip, h.name, h.metrics?.cpu_percent ?? null, h.metrics?.memory_percent ?? null, h.services)
)
// K3s #2 (121) 若 API 未回傳,補靜態卡
const has121 = apiHosts.some(h => h.ip === '192.168.0.121')
if (!has121) {
apiHosts.push(buildHostInfo('192.168.0.121', 'K3s Server #2', null, null, []))
}
return apiHosts
})()} />
</div>
{/* 監控工具 */}

View File

@@ -1,16 +1,21 @@
'use client'
/**
* HostGrid - 2×2 主機緊湊 Grid
* HostGrid - 主機完整服務 Grid
* ================================
* 每hostname + IP + CPU/RAM + 3條關鍵服務 mini 列
* hover 橘紅邊框,異常指標用警示色
* 每張卡顯示hostname + IP + CPU/RAM + 完整服務列表
* K3s 主機: 藍色標記、K3s 徽章、獨立 Cluster 區塊
* 統帥鐵律:無數據顯示 "--",禁止假數據
*
* @updated 2026-04-03 Claude Code — 完整服務清單 + K3s Cluster 突顯
*/
export interface HostService {
name: string
healthy: boolean
port?: number
description?: string
isK3s?: boolean
}
export interface HostInfo {
@@ -19,6 +24,8 @@ export interface HostInfo {
cpuPct: number | null
ramPct: number | null
services: HostService[]
isK3s?: boolean
role?: string
}
export interface HostGridProps {
@@ -26,84 +33,139 @@ export interface HostGridProps {
}
function MetricBar({ value, warn = 80, critical = 90 }: { value: number | null; warn?: number; critical?: number }) {
if (value === null) return <span style={{ color: '#b0ad9f', fontSize: 12 }}>--</span>
if (value === null) return <span style={{ color: '#b0ad9f', fontSize: 11 }}>--</span>
const color = value >= critical ? '#cc2200' : value >= warn ? '#F59E0B' : '#22C55E'
return <span style={{ color, fontSize: 11, fontWeight: 600 }}>{value}%</span>
}
function ServiceRow({ svc }: { svc: HostService }) {
const isK3s = svc.isK3s ?? false
const dotColor = isK3s ? '#4f8ef7' : svc.healthy ? '#22C55E' : '#b0ad9f'
return (
<span style={{ color, fontSize: 12, fontWeight: 600 }}>{value}%</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minHeight: 17 }}>
<div style={{ width: 4, height: 4, borderRadius: '50%', background: dotColor, flexShrink: 0 }} />
<span style={{
fontSize: 11,
fontWeight: isK3s ? 600 : 400,
color: isK3s ? '#3b7de8' : '#49483f',
fontFamily: 'var(--font-body), monospace',
flexShrink: 0,
}}>
{svc.name}
</span>
{svc.port && (
<span style={{ fontSize: 10, color: '#b0ad9f' }}>:{svc.port}</span>
)}
{svc.description && (
<span style={{ fontSize: 10, color: '#b0ad9f', marginLeft: 'auto', flexShrink: 0, textAlign: 'right' }}>
{svc.description}
</span>
)}
</div>
)
}
function HostCard({ host }: { host: HostInfo }) {
const isK3s = host.isK3s ?? false
const borderColor = isK3s ? '#93c5fd' : '#e0ddd4'
const headerBg = isK3s ? '#eff6ff' : '#f5f4ed'
const accentColor = isK3s ? '#3b7de8' : '#d97757'
return (
<div
style={{
border: `0.5px solid ${borderColor}`,
borderRadius: 8,
overflow: 'hidden',
background: '#fff',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.borderColor = accentColor }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.borderColor = borderColor }}
>
{/* Header */}
<div style={{ background: headerBg, borderBottom: `0.5px solid ${borderColor}`, padding: '6px 9px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: accentColor, flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>
{host.hostname}
</span>
{isK3s && (
<span style={{
fontSize: 9, fontWeight: 700, color: '#1d4ed8',
background: '#dbeafe', borderRadius: 3, padding: '1px 4px', letterSpacing: '0.03em',
}}>
K3s
</span>
)}
</div>
<span style={{ fontSize: 10, color: '#b0ad9f', fontFamily: 'monospace' }}>{host.ip}</span>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#b0ad9f' }}>CPU <MetricBar value={host.cpuPct} /></span>
<span style={{ fontSize: 10, color: '#b0ad9f' }}>RAM <MetricBar value={host.ramPct} /></span>
{host.role && (
<span style={{ fontSize: 10, color: '#4f8ef7', marginLeft: 'auto' }}>{host.role}</span>
)}
</div>
</div>
{/* Services */}
<div style={{ padding: '5px 9px 7px' }}>
{host.services.length === 0 ? (
<span style={{ fontSize: 11, color: '#b0ad9f' }}>--</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{host.services.map((svc, i) => <ServiceRow key={i} svc={svc} />)}
</div>
)}
</div>
</div>
)
}
export function HostGrid({ hosts }: HostGridProps) {
// 補足至 4 格(不足時顯示空格)
const slots = [...hosts, ...Array(Math.max(0, 4 - hosts.length)).fill(null)]
if (hosts.length === 0) {
return <div style={{ padding: 16, textAlign: 'center', color: '#b0ad9f', fontSize: 12 }}>--</div>
}
const normalHosts = hosts.filter(h => !h.isK3s)
const k3sHosts = hosts.filter(h => h.isK3s)
return (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 6,
padding: '0 10px 8px',
}}>
{slots.map((host: HostInfo | null, idx) => (
<div
key={idx}
style={{
border: '0.5px solid #e0ddd4',
borderRadius: 8,
padding: '7px 9px',
background: '#f5f4ed',
cursor: host ? 'pointer' : 'default',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => host && ((e.currentTarget as HTMLDivElement).style.borderColor = '#d97757')}
onMouseLeave={e => host && ((e.currentTarget as HTMLDivElement).style.borderColor = '#e0ddd4')}
>
{host ? (
<>
{/* Hostname + IP */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', background: '#22C55E', flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>
{host.hostname}
</span>
</div>
<span style={{ fontSize: 12, color: '#87867f' }}>{host.ip}</span>
</div>
{/* CPU / RAM */}
<div style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#b0ad9f' }}>
CPU <MetricBar value={host.cpuPct} />
</span>
<span style={{ fontSize: 12, color: '#b0ad9f' }}>
RAM <MetricBar value={host.ramPct} />
</span>
</div>
{/* 3 關鍵服務 mini 列 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{host.services.slice(0, 3).map((svc, si) => (
<div key={si} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{
width: 4, height: 4, borderRadius: '50%',
background: svc.healthy ? '#22C55E' : '#cc2200',
flexShrink: 0,
}} />
<span style={{ fontSize: 12, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
{svc.name}
</span>
</div>
))}
</div>
</>
) : (
<div style={{ height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 12, color: '#b0ad9f' }}>--</span>
</div>
)}
<div style={{ padding: '0 10px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Normal hosts — 2-col grid */}
{normalHosts.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{normalHosts.map((host, i) => <HostCard key={i} host={host} />)}
</div>
))}
)}
{/* K3s Cluster — full-width highlighted block */}
{k3sHosts.length > 0 && (
<div style={{ border: '1px solid #93c5fd', borderRadius: 8, overflow: 'hidden', background: '#f8fbff' }}>
{/* Cluster banner */}
<div style={{
background: '#dbeafe',
borderBottom: '0.5px solid #93c5fd',
padding: '4px 10px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<span style={{ fontSize: 10, fontWeight: 800, color: '#1d4ed8', letterSpacing: '0.08em' }}>
K3S CLUSTER (HA)
</span>
<span style={{ fontSize: 10, color: '#3b7de8' }}>
VIP 192.168.0.125 · kubectl :6443 · Web :32335 · API :32334
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: 6 }}>
{k3sHosts.map((host, i) => <HostCard key={i} host={host} />)}
</div>
</div>
)}
</div>
)
}