feat(infra): 完整監控工具 + 主機服務清單 + K3s Cluster 突顯
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 6m50s
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 監控工具 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user