fix: 修复 Agent 状态与监控大屏

This commit is contained in:
Logan.Li committed 2026-07-27 15:56:03 +08:00
1 parent 83662487cb
commit e557782176
9 files changed
+155 -591

No files matched your search

+38 -29
View File
@@ -61,29 +61,30 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
// 存储到数据库
report := model.AgentReport{
Hostname: req.Hostname,
CPUUsage: req.CPU.UsagePercent,
CPUIOwait: req.CPU.IOWait,
CPUCores: req.CPU.CoreCount,
Load1: req.CPU.Load1,
Load5: req.CPU.Load5,
Load15: req.CPU.Load15,
MemTotal: req.Memory.TotalGB,
MemUsed: req.Memory.UsedGB,
MemUsage: req.Memory.UsagePercent,
Hostname: req.Hostname,
IP: strings.TrimSpace(req.IP), // 使用 Agent 自报 IP,避免反向代理地址误关联。 // Logan.Li
CPUUsage: req.CPU.UsagePercent,
CPUIOwait: req.CPU.IOWait,
CPUCores: req.CPU.CoreCount,
Load1: req.CPU.Load1,
Load5: req.CPU.Load5,
Load15: req.CPU.Load15,
MemTotal: req.Memory.TotalGB,
MemUsed: req.Memory.UsedGB,
MemUsage: req.Memory.UsagePercent,
MemAvailable: req.Memory.AvailableGB,
MemSwapUsed: req.Memory.SwapUsedGB,
DiskInfo: string(diskJSON),
PortInfo: string(portJSON),
NetInfo: string(netJSON),
DiskIOInfo: string(dioJSON),
DiskInfo: string(diskJSON),
PortInfo: string(portJSON),
NetInfo: string(netJSON),
DiskIOInfo: string(dioJSON),
// v2.0 新增
SwapInfo: string(swapJSON),
ProcessTopInfo: string(processTopJSON),
FDInfo: string(fdJSON),
CSInfo: string(csJSON),
HeartbeatInfo: string(heartbeatJSON),
ReportTime: reportTime,
ReportTime: reportTime,
}
if err := h.db.Create(&report).Error; err != nil {
@@ -99,9 +100,9 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
for _, d := range req.Disks {
totalDiskGB += d.TotalGB
}
memGB := math.Ceil(req.Memory.TotalGB) // 31.34 → 32, 62.79 → 63(物理内存向上取整)
memMB := int64(memGB * 1024) // GB → MB
diskGB := int64(totalDiskGB + 0.5) // 39.99 → 40
memGB := math.Ceil(req.Memory.TotalGB) // 31.34 → 32, 62.79 → 63(物理内存向上取整)
memMB := int64(memGB * 1024) // GB → MB
diskGB := int64(totalDiskGB + 0.5) // 39.99 → 40
clientIP := c.ClientIP()
@@ -277,7 +278,7 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64,
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
var results []model.AgentLatestResponse
threeMinAgo := time.Now().Add(-3 * time.Minute)
// 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报
// 以 servers 表为主,按资产名或 Agent 自报 IP 关联最近 3 分钟内的上报。 // Logan.Li
// 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环
h.db.Raw(`
SELECT
@@ -302,7 +303,8 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
a.report_time,
COALESCE(s.agent_version, '') as agent_version,
CASE
WHEN a.hostname IS NOT NULL THEN COALESCE(s.agent_status, 'online')
WHEN a.id IS NOT NULL THEN 'online'
WHEN COALESCE(s.agent_version, '') <> '' OR COALESCE(s.agent_status, '') = 'online' THEN 'offline'
ELSE 'not_installed'
END as agent_status,
COALESCE(s.os, '') as os,
@@ -312,14 +314,13 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
COALESCE(r.name, '') as region,
CASE WHEN s.password != '' OR s.ssh_key != '' THEN true ELSE false END as has_auth
FROM asset_servers s
LEFT JOIN (
SELECT * FROM monitor_agent_reports
WHERE id IN (
SELECT MAX(id) FROM monitor_agent_reports
WHERE report_time >= ?
GROUP BY hostname
)
) a ON a.hostname = s.hostname
LEFT JOIN monitor_agent_reports a ON a.id = (
SELECT ar.id FROM monitor_agent_reports ar
WHERE ar.report_time >= ?
AND (ar.hostname = s.hostname OR (ar.ip <> '' AND ar.ip = s.ip))
ORDER BY ar.report_time DESC, ar.id DESC
LIMIT 1
)
LEFT JOIN asset_regions r ON r.id = s.region_id
ORDER BY a.report_time DESC, s.id ASC
`, threeMinAgo).Scan(&results)
@@ -409,8 +410,16 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) {
return
}
var server model.Server
serverIP := ""
if h.db.Where("hostname = ?", hostname).Take(&server).Error == nil {
serverIP = strings.TrimSpace(server.IP)
}
var reports []model.AgentReport
h.db.Where("hostname = ? AND report_time >= ? AND report_time <= ?", hostname, since, until).
// 资产名可能与操作系统主机名不同,优先兼容 Agent 自报 IP。 // Logan.Li
h.db.Where("hostname = ? OR (ip <> '' AND ip = ?)", hostname, serverIP).
Where("report_time >= ? AND report_time <= ?", since, until).
Order("report_time ASC").
Find(&reports)
+60 -60
View File
@@ -6,33 +6,34 @@ import (
// AgentReport Agent 上报的原始指标数据 // Logan.Li
type AgentReport struct {
ID uint `gorm:"primarykey" json:"id"`
Hostname string `gorm:"size:255;index;not null" json:"hostname"`
CPUUsage float64 `gorm:"type:decimal(6,2)" json:"cpu_usage"`
CPUIOwait float64 `gorm:"type:decimal(6,2)" json:"cpu_iowait"`
CPUCores int `json:"cpu_cores"`
Load1 float64 `gorm:"type:decimal(8,2)" json:"load1"`
Load5 float64 `gorm:"type:decimal(8,2)" json:"load5"`
Load15 float64 `gorm:"type:decimal(8,2)" json:"load15"`
MemTotal float64 `gorm:"type:decimal(10,2)" json:"mem_total"`
MemUsed float64 `gorm:"type:decimal(10,2)" json:"mem_used"`
MemUsage float64 `gorm:"type:decimal(6,2)" json:"mem_usage"`
MemAvailable float64 `gorm:"type:decimal(10,2)" json:"mem_available"`
MemSwapUsed float64 `gorm:"type:decimal(10,2)" json:"mem_swap_used"`
DiskInfo string `gorm:"type:text" json:"disk_info"`
PortInfo string `gorm:"type:text" json:"port_info"`
NetInfo string `gorm:"type:text" json:"net_info"`
DiskIOInfo string `gorm:"type:text" json:"disk_io_info"`
ID uint `gorm:"primarykey" json:"id"`
Hostname string `gorm:"size:255;index;not null" json:"hostname"`
IP string `gorm:"size:64;index" json:"ip"` // Agent 上报的本机 IP,用于资产别名关联。 // Logan.Li
CPUUsage float64 `gorm:"type:decimal(6,2)" json:"cpu_usage"`
CPUIOwait float64 `gorm:"type:decimal(6,2)" json:"cpu_iowait"`
CPUCores int `json:"cpu_cores"`
Load1 float64 `gorm:"type:decimal(8,2)" json:"load1"`
Load5 float64 `gorm:"type:decimal(8,2)" json:"load5"`
Load15 float64 `gorm:"type:decimal(8,2)" json:"load15"`
MemTotal float64 `gorm:"type:decimal(10,2)" json:"mem_total"`
MemUsed float64 `gorm:"type:decimal(10,2)" json:"mem_used"`
MemUsage float64 `gorm:"type:decimal(6,2)" json:"mem_usage"`
MemAvailable float64 `gorm:"type:decimal(10,2)" json:"mem_available"`
MemSwapUsed float64 `gorm:"type:decimal(10,2)" json:"mem_swap_used"`
DiskInfo string `gorm:"type:text" json:"disk_info"`
PortInfo string `gorm:"type:text" json:"port_info"`
NetInfo string `gorm:"type:text" json:"net_info"`
DiskIOInfo string `gorm:"type:text" json:"disk_io_info"`
// ──── v2.0 新增字段 ────
SwapInfo string `gorm:"type:text" json:"swap_info"` // Swap JSON
ProcessTopInfo string `gorm:"type:text" json:"process_top_info"` // 进程 TOP JSON
FDInfo string `gorm:"type:text" json:"fd_info"` // FD 句柄 JSON
CSInfo string `gorm:"type:text" json:"cs_info"` // 上下文切换 JSON
HeartbeatInfo string `gorm:"type:text" json:"heartbeat_info"` // Agent 心跳 JSON
SwapInfo string `gorm:"type:text" json:"swap_info"` // Swap JSON
ProcessTopInfo string `gorm:"type:text" json:"process_top_info"` // 进程 TOP JSON
FDInfo string `gorm:"type:text" json:"fd_info"` // FD 句柄 JSON
CSInfo string `gorm:"type:text" json:"cs_info"` // 上下文切换 JSON
HeartbeatInfo string `gorm:"type:text" json:"heartbeat_info"` // Agent 心跳 JSON
// ──────────────────────────
ReportTime time.Time `json:"report_time"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ReportTime time.Time `json:"report_time"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AgentReport) TableName() string {
@@ -41,20 +42,20 @@ func (AgentReport) TableName() string {
// AgentLatestResponse Agent 最新状态(含服务器元信息)
type AgentLatestResponse struct {
ServerID uint `json:"server_id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
CPUUsage float64 `json:"cpu_usage"`
CPUCores int `json:"cpu_cores"`
MemUsage float64 `json:"mem_usage"`
MemTotal float64 `json:"mem_total"`
MemUsed float64 `json:"mem_used"`
DiskUsage float64 `json:"disk_usage"`
DiskTotal int64 `json:"disk_total"`
DiskInfo string `json:"disk_info"`
PortInfo string `json:"port_info"`
NetInfo string `json:"net_info"`
DiskIOInfo string `json:"disk_io_info"`
ServerID uint `json:"server_id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
CPUUsage float64 `json:"cpu_usage"`
CPUCores int `json:"cpu_cores"`
MemUsage float64 `json:"mem_usage"`
MemTotal float64 `json:"mem_total"`
MemUsed float64 `json:"mem_used"`
DiskUsage float64 `json:"disk_usage"`
DiskTotal int64 `json:"disk_total"`
DiskInfo string `json:"disk_info"`
PortInfo string `json:"port_info"`
NetInfo string `json:"net_info"`
DiskIOInfo string `json:"disk_io_info"`
// ──── v2.0 新增 ────
SwapInfo string `json:"swap_info"`
ProcessTopInfo string `json:"process_top_info"`
@@ -74,23 +75,23 @@ type AgentLatestResponse struct {
// AgentReportRequest Agent 上报请求体
type AgentReportRequest struct {
Hostname string `json:"hostname" binding:"required"`
IP string `json:"ip"`
Timestamp string `json:"timestamp"`
AgentVersion string `json:"agent_version"`
CPU CPUMetric `json:"cpu"`
Memory MemMetric `json:"memory"`
Swap *SwapMetric `json:"swap,omitempty"`
Disks []DiskMetric `json:"disks"`
Ports []PortMetric `json:"ports"`
Network NetworkStatModel `json:"network"`
DiskIO DiskIOStatModel `json:"disk_io"`
System *SystemStatModel `json:"system,omitempty"`
Hostname string `json:"hostname" binding:"required"`
IP string `json:"ip"`
Timestamp string `json:"timestamp"`
AgentVersion string `json:"agent_version"`
CPU CPUMetric `json:"cpu"`
Memory MemMetric `json:"memory"`
Swap *SwapMetric `json:"swap,omitempty"`
Disks []DiskMetric `json:"disks"`
Ports []PortMetric `json:"ports"`
Network NetworkStatModel `json:"network"`
DiskIO DiskIOStatModel `json:"disk_io"`
System *SystemStatModel `json:"system,omitempty"`
// ──── v2.0 新增 ────
ProcessTop []ProcessMetric `json:"process_top,omitempty"`
FD *FDMetric `json:"fd,omitempty"`
ContextSwitches *CSMetric `json:"context_switches,omitempty"`
Heartbeat HeartbeatMetric `json:"heartbeat"`
ProcessTop []ProcessMetric `json:"process_top,omitempty"`
FD *FDMetric `json:"fd,omitempty"`
ContextSwitches *CSMetric `json:"context_switches,omitempty"`
Heartbeat HeartbeatMetric `json:"heartbeat"`
ProbeResults []HealthProbeResult `json:"probe_results,omitempty"` // v2.1 健康探针结果
}
@@ -198,10 +199,10 @@ type DiskIOStatModel struct {
// AgentPortPolicy 主机端口监控策略 // Logan.Li
type AgentPortPolicy struct {
ID uint `gorm:"primarykey" json:"id"`
Hostname string `gorm:"size:255;uniqueIndex;not null" json:"hostname"`
PortFilterMode string `gorm:"size:20;default:whitelist;not null" json:"port_filter_mode"` // whitelist / blacklist
PortWhitelist string `gorm:"type:text" json:"port_whitelist"` // JSON 数组,如 [80,443,3306,6379]
ID uint `gorm:"primarykey" json:"id"`
Hostname string `gorm:"size:255;uniqueIndex;not null" json:"hostname"`
PortFilterMode string `gorm:"size:20;default:whitelist;not null" json:"port_filter_mode"` // whitelist / blacklist
PortWhitelist string `gorm:"type:text" json:"port_whitelist"` // JSON 数组,如 [80,443,3306,6379]
}
func (AgentPortPolicy) TableName() string {
@@ -214,4 +215,3 @@ type AgentPortPolicyDTO struct {
PortFilterMode string `json:"port_filter_mode"`
PortWhitelist []int `json:"port_whitelist"`
}
+1 -2
View File
@@ -63,7 +63,6 @@ const TraceManager = lazy(() => import('@/components/monitor/trace/TraceManager'
const HostMonitor = lazy(() => import('@/pages/monitor/HostMonitor').then(m => ({ default: m.HostMonitor })));
const GrafanaDashboard = lazy(() => import('@/components/monitor/dashboard/GrafanaDashboard').then(m => ({ default: m.default })));
const MonitorCenter = lazy(() => import('@/pages/monitor/MonitorCenter').then(m => ({ default: m.MonitorCenter })));
const HostMonitorDashboard = lazy(() => import('@/components/monitor/dashboard/HostMonitorDashboard'));
const MetricsExplorer = lazy(() => import('@/pages/monitor/MetricsExplorer').then(m => ({ default: m.MetricsExplorer })));
const DatasourcePage = lazy(() => import('@/components/monitor/datasource/DatasourcePage').then(m => ({ default: m.default })));
const LogCenter = lazy(() => import('@/components/monitor/log-center/LogCenter'));
@@ -187,7 +186,7 @@ function App() {
<Route path="alerts" element={<LazyPage><AlertManager /></LazyPage>} />
<Route path="alert-center" element={<LazyPage><AlertCenter /></LazyPage>} />
<Route path="monitor-center" element={<LazyPage><MonitorCenter /></LazyPage>} />
<Route path="host-dashboard" element={<LazyPage><HostMonitorDashboard /></LazyPage>} />
<Route path="host-dashboard" element={<Navigate to="/dashboards" replace />} />
<Route path="log-center" element={<Navigate to="/log-center/loki" replace />} />
<Route path="log-center/:section" element={<LazyPage><LogCenter /></LazyPage>} />
<Route path="logs" element={<Navigate to="/log-center/loki" replace />} />
@@ -50,7 +50,6 @@ const ROUTE_I18N_MAP: Record<string, string> = {
'datasources': 'breadcrumb.datasources',
'dashboards': 'breadcrumb.dashboards',
'host-monitor': 'breadcrumb.hostMonitor',
'host-dashboard': 'breadcrumb.hostDashboard',
'alerts': 'breadcrumb.alerts',
'approval': 'breadcrumb.approval',
'approval-resource': 'breadcrumb.approvalResource',
@@ -5,7 +5,6 @@ import { cn } from '@/lib/utils';
import { useThemeStore, useMenuStore } from '@/store';
import { usePermission } from '@/hooks/usePermission';
import {
LayoutDashboard,
Server,
Cloud,
@@ -115,7 +114,6 @@ const menuItems: MenuItem[] = [
children: [
{ id: 'grafana', name: 'Dashboards', labelKey: 'menu.grafana', icon: Monitor, path: '/dashboards', permission: 'monitor:alert' },
{ id: 'monitor-center', name: 'Resource Monitor', labelKey: 'menu.resourceMonitor', icon: Activity, path: '/monitor-center', permission: 'agent:monitor' },
{ id: 'host-dashboard', name: 'Host Dashboard', labelKey: 'menu.hostDashboard', icon: LayoutDashboard, path: '/host-dashboard', permission: 'agent:monitor' },
{ id: 'trace', name: 'Tracing', labelKey: 'menu.trace', icon: GitBranch, path: '/trace', permission: 'monitor:alert' },
{ id: 'alert-center', name: 'Alert Center', labelKey: 'menu.alertCenter', icon: Bell, path: '/alert-center', permission: 'monitor:alert' },
{ id: 'datasources', name: 'Datasources', labelKey: 'menu.datasources', icon: Database, path: '/datasources', permission: 'monitor:alert' },
@@ -1,497 +0,0 @@
// 主机监控大屏 — Grafana 风格:统计卡片 + 资源条状图 + 可排序列表 // Logan.Li
// 参照: Grafana node-dashboard.json (表格+时序) / kubernetes-dashboard.json (bargauge+阈值)
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Server,
Cpu,
HardDrive,
Activity,
Monitor,
Bell,
ArrowUpDown,
ArrowUp,
ArrowDown,
RefreshCw,
Search,
ChevronLeft,
ChevronRight,
Wifi,
WifiOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { serverApi } from '@/api';
import type { JsonData } from '@/types/common';
type SortField = 'hostname' | 'ip' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'memory_usage' | 'disk_usage' | 'region';
type SortDir = 'asc' | 'desc';
function getUsageBar(val: number) {
const v = val || 0;
const color = v > 85 ? 'bg-red-500' : v > 60 ? 'bg-amber-500' : 'bg-emerald-500';
return (
<div className="flex items-center gap-1.5 min-w-[80px]">
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-active)]/50 overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${Math.min(v, 100)}%` }} />
</div>
<span className="text-xs font-mono w-10 text-right text-[var(--dg-text-primary)]">{v.toFixed(0)}%</span>
</div>
);
}
export default function HostMonitorDashboard() {
const { t } = useTranslation(['monitor', 'common']);
const navigate = useNavigate();
const [hosts, setHosts] = useState<JsonData[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 排序状态
const [sortField, setSortField] = useState<SortField>('hostname');
const [sortDir, setSortDir] = useState<SortDir>('asc');
// 统计
const [stats, setStats] = useState<{ online: number; offline: number; total: number }>({ online: 0, offline: 0, total: 0 });
const loadData = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const params: JsonData = { page, page_size: pageSize };
if (search.trim()) params.keyword = search.trim();
const res = await serverApi.getServers(params);
const list = (res as JsonData)?.list || [];
setHosts(list);
setTotal((res as JsonData)?.total || 0);
// 统计
const online = list.filter((h: JsonData) => h.agent_status === 'online').length;
setStats({ online, offline: list.length - online, total: (res as JsonData)?.total || 0 });
} catch {
setHosts([]);
setTotal(0);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [page, pageSize, search]);
useEffect(() => { loadData(); }, [loadData]);
// 排序后的数据
const sorted = [...hosts].sort((a: JsonData, b: JsonData) => {
let va: JsonData | undefined, vb: JsonData | undefined;
if (sortField === 'region') {
va = (a.region?.name || a.region?.Name || '').toLowerCase();
vb = (b.region?.name || b.region?.Name || '').toLowerCase();
} else if (sortField === 'agent_status') {
va = a.agent_status || 'offline';
vb = b.agent_status || 'offline';
} else if (sortField === 'agent_version') {
va = a.agent_version || '';
vb = b.agent_version || '';
} else {
va = a[sortField] || '';
vb = b[sortField] || '';
}
if (typeof va === 'number' && typeof vb === 'number') {
return sortDir === 'asc' ? va - vb : vb - va;
}
const sa = String(va).toLowerCase();
const sb = String(vb).toLowerCase();
if (sa < sb) return sortDir === 'asc' ? -1 : 1;
if (sa > sb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('asc');
}
};
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) return <ArrowUpDown className="w-3 h-3 dg-text-muted" />;
return sortDir === 'asc'
? <ArrowUp className="w-3 h-3 dg-text-blue" />
: <ArrowDown className="w-3 h-3 dg-text-blue" />;
};
const totalPages = Math.ceil(total / pageSize);
const handleSearch = () => {
setPage(1);
loadData();
};
// 状态标签颜色
const statusBadge = (status: string) => {
if (status === 'online') return <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30 text-[11px]">{t('common:online')}</Badge>;
if (status === 'running') return <Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-[11px]">{t('common:running')}</Badge>;
if (status === 'stopped') return <Badge className="bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)] text-[11px]">{t('common:stopped')}</Badge>;
return <Badge variant="outline" className="text-[11px] dg-text-muted">{status || '--'}</Badge>;
};
return (
<div className="space-y-4">
{/* 统计卡片 — Grafana Quick Facts 风格 (参照 JVM Dashboard) */}
<div className="grid grid-cols-6 gap-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-blue-500/10"><Server className="h-4 w-4 dg-text-blue" /></div>
<div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">{stats.total}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor:hostDashboard.hostTotal')}</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-emerald-500/10"><Wifi className="h-4 w-4 dg-text-green" /></div>
<div>
<div className="text-xl font-bold dg-text-green">{stats.online}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor:hostDashboard.agentOnline')}</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-[var(--dg-bg-active)]/10"><WifiOff className="h-4 w-4 dg-text-secondary" /></div>
<div>
<div className="text-xl font-bold dg-text-secondary">{stats.offline}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('common:offline')}</div>
</div>
</CardContent>
</Card>
{(() => {
const onlines = hosts.filter((h: JsonData) => h.agent_status === 'online');
const avgCpu = onlines.length > 0 ? onlines.reduce((s: number, h: JsonData) => s + (h.cpu_usage || 0), 0) / onlines.length : 0;
const avgMem = onlines.length > 0 ? onlines.reduce((s: number, h: JsonData) => s + (h.memory_usage || 0), 0) / onlines.length : 0;
const cpuColor = avgCpu > 85 ? 'dg-text-red' : avgCpu > 60 ? 'dg-text-yellow' : 'dg-text-green';
const memColor = avgMem > 85 ? 'dg-text-red' : avgMem > 60 ? 'dg-text-yellow' : 'dg-text-green';
return (<>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-red-500/10"><Cpu className="h-4 w-4 dg-text-red" /></div>
<div>
<div className={`text-xl font-bold ${cpuColor}`}>{avgCpu.toFixed(1)}<span className="text-sm">%</span></div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor:hostDashboard.avgCpu')}</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-amber-500/10"><Server className="h-4 w-4 dg-text-yellow" /></div>
<div>
<div className={`text-xl font-bold ${memColor}`}>{avgMem.toFixed(1)}<span className="text-sm">%</span></div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor:hostDashboard.avgMemory')}</div>
</div>
</CardContent>
</Card>
</>);
})()}
</div>
{/* 资源条状图 — Grafana Bargauge 风格 (参照 K8s Node CPU/Memory Ratio) */}
{hosts.filter((h: JsonData) => h.agent_status === 'online').length > 0 && (
<div className="grid grid-cols-2 gap-3">
{/* CPU 排行 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2 text-[var(--dg-text-primary)]">
<Cpu className="h-4 w-4 dg-text-red" />
{t('monitor:hostDashboard.cpuTop5')}
<Badge variant="outline" className="text-[10px] bg-red-500/10 dg-text-red border-red-500/30">{t('monitor:hostDashboard.threshold')}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{[...hosts]
.filter((h: JsonData) => h.agent_status === 'online')
.sort((a: JsonData, b: JsonData) => (b.cpu_usage || 0) - (a.cpu_usage || 0))
.slice(0, 5)
.map((h: JsonData) => {
const val = h.cpu_usage || 0;
return (
<div key={h.id} className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-secondary)] w-20 truncate" title={h.hostname}>{h.hostname}</span>
<div className="flex-1 h-4 rounded bg-[var(--dg-bg-active)]/50 overflow-hidden">
<div
className="h-full rounded transition-all"
style={{
width: `${Math.min(val, 100)}%`,
background: val >= 85
? 'linear-gradient(90deg, #ef4444, #f87171)'
: val >= 60
? 'linear-gradient(90deg, #f59e0b, #fbbf24)'
: 'linear-gradient(90deg, #10b981, #34d399)'
}}
/>
</div>
<span className={`text-xs font-mono w-10 text-right ${
val >= 85 ? 'dg-text-red' : val >= 60 ? 'dg-text-yellow' : 'dg-text-green'
}`}>{val.toFixed(1)}%</span>
</div>
);
})}
</CardContent>
</Card>
{/* 内存排行 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2 text-[var(--dg-text-primary)]">
<Server className="h-4 w-4 dg-text-yellow" />
{t('monitor:hostDashboard.memTop5')}
<Badge variant="outline" className="text-[10px] bg-amber-500/10 dg-text-yellow border-amber-500/30">{t('monitor:hostDashboard.threshold')}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{[...hosts]
.filter((h: JsonData) => h.agent_status === 'online')
.sort((a: JsonData, b: JsonData) => (b.memory_usage || 0) - (a.memory_usage || 0))
.slice(0, 5)
.map((h: JsonData) => {
const val = h.memory_usage || 0;
return (
<div key={h.id} className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-secondary)] w-20 truncate" title={h.hostname}>{h.hostname}</span>
<div className="flex-1 h-4 rounded bg-[var(--dg-bg-active)]/50 overflow-hidden">
<div
className="h-full rounded transition-all"
style={{
width: `${Math.min(val, 100)}%`,
background: val >= 85
? 'linear-gradient(90deg, #ef4444, #f87171)'
: val >= 60
? 'linear-gradient(90deg, #f59e0b, #fbbf24)'
: 'linear-gradient(90deg, #10b981, #34d399)'
}}
/>
</div>
<span className={`text-xs font-mono w-10 text-right ${
val >= 85 ? 'dg-text-red' : val >= 60 ? 'dg-text-yellow' : 'dg-text-green'
}`}>{val.toFixed(1)}%</span>
</div>
);
})}
</CardContent>
</Card>
</div>
)}
{/* 表格卡片 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 dg-text-green" />
<CardTitle className="text-base text-[var(--dg-text-primary)]">{t('monitor:hostDashboard.listTitle')}</CardTitle>
<Badge variant="outline" className="text-xs bg-emerald-500/10 dg-text-green border-emerald-500/30">{t('monitor:hostDashboard.realtime')}</Badge>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input
className="w-[200px] pl-8 h-8 text-sm bg-[var(--dg-bg-primary)]"
placeholder={t('monitor:hostDashboard.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Button variant="outline" size="sm" onClick={() => loadData(true)} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 mr-1 ${refreshing ? 'animate-spin' : ''}`} />
{t('common:refresh')}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="py-16 text-center text-[var(--dg-text-muted)]">
<Activity className="h-8 w-8 mx-auto mb-2 animate-spin dg-text-green" />
<p className="text-sm">{t('common:loading')}</p>
</div>
) : sorted.length === 0 ? (
<div className="py-16 text-center text-[var(--dg-text-muted)]">
<Server className="h-10 w-10 mx-auto mb-2 dg-text-muted" />
<p className="text-sm">{t('monitor:hostDashboard.noData')}</p>
<p className="text-xs mt-1">{t('monitor:hostDashboard.noDataDesc')}</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[var(--dg-border)]">
{[
{ key: 'hostname', label: t('monitor:hostDashboard.colHostId'), w: '' },
{ key: 'agent_version', label: t('monitor:hostDashboard.colAgentVersion'), w: 'w-[100px]' },
{ key: 'agent_status', label: t('monitor:hostDashboard.colAgentStatus'), w: 'w-[90px]' },
{ key: 'cpu_usage', label: 'CPU', w: 'w-[130px]' },
{ key: 'memory_usage', label: t('monitor:hostMetric.memory'), w: 'w-[130px]' },
{ key: 'disk_usage', label: t('monitor:hostMetric.disk'), w: 'w-[130px]' },
{ key: 'region', label: t('monitor:hostDashboard.colRegion'), w: 'w-[120px]' },
{ key: 'ip', label: t('monitor:hostDashboard.colIp'), w: 'w-[140px]' },
].map(col => (
<th
key={col.key}
className={`text-left px-3 py-2.5 text-xs font-medium text-[var(--dg-text-muted)] cursor-pointer select-none hover:text-[var(--dg-text-primary)] transition-colors ${col.w}`}
onClick={() => handleSort(col.key as SortField)}
>
<div className="flex items-center gap-1">
{col.label}
<SortIcon field={col.key as SortField} />
</div>
</th>
))}
<th className="text-left px-3 py-2.5 text-xs font-medium text-[var(--dg-text-muted)]">{t('common:actions')}</th>
</tr>
</thead>
<tbody>
{sorted.map((h: JsonData) => (
<tr key={h.id} className="border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors">
{/* 主机名/ID */}
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${h.agent_status === 'online' ? 'bg-emerald-400' : h.status === 'running' ? 'bg-blue-400' : 'bg-[var(--dg-bg-active)]'}`} />
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{h.hostname}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">ID: {h.id}</div>
</div>
</div>
</td>
{/* Agent 版本 */}
<td className="px-3 py-2">
<code className="text-xs text-blue-300">{h.agent_version || '--'}</code>
</td>
{/* Agent 状态 */}
<td className="px-3 py-2">
{statusBadge(h.agent_status)}
</td>
{/* CPU 使用率 */}
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<Cpu className="h-3 w-3 dg-text-red flex-shrink-0" />
{getUsageBar(h.cpu_usage)}
</div>
</td>
{/* 内存使用率 */}
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<Server className="h-3 w-3 dg-text-yellow flex-shrink-0" />
{getUsageBar(h.memory_usage)}
</div>
</td>
{/* 磁盘使用率 */}
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3 w-3 dg-text-orange flex-shrink-0" />
{getUsageBar(h.disk_usage)}
</div>
</td>
{/* 所在区域 */}
<td className="px-3 py-2">
<span className="text-xs text-[var(--dg-text-secondary)]">
{h.region?.name || h.region?.Name || (h.region ? h.region : '--')}
</span>
</td>
{/* IP 地址 */}
<td className="px-3 py-2">
<code className="text-xs text-[var(--dg-text-secondary)] font-mono">{h.ip || '--'}</code>
</td>
{/* 操作栏 */}
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs dg-text-blue hover:text-blue-300 hover:bg-blue-500/10"
onClick={() => navigate(`/monitor-center?host=${encodeURIComponent(h.hostname)}`)}
title={t('monitor:hostDashboard.viewMonitorChart')}
>
<Monitor className="h-3 w-3 mr-1" />
{t('monitor:hostDashboard.monitor')}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs dg-text-yellow hover:text-amber-300 hover:bg-amber-500/10"
onClick={() => navigate(`/alerts?hostname=${encodeURIComponent(h.hostname)}`)}
title={t('monitor:hostDashboard.viewAlertRules')}
>
<Bell className="h-3 w-3 mr-1" />
{t('monitor:hostDashboard.alert')}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
<span>{t('monitor:hostDashboard.perPage')}</span>
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
<SelectTrigger className="h-7 w-[60px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="30">30</SelectItem>
</SelectContent>
</Select>
<span>{t('monitor:hostDashboard.totalCount', { count: total })}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-[var(--dg-text-secondary)] px-2">
{page} / {Math.max(totalPages, 1)}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -14,6 +14,8 @@ import {
Trash2,
Wifi,
WifiOff,
Cpu,
HardDrive,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -140,6 +142,25 @@ export const AgentTab: React.FC = () => {
const selectedHosts = useMemo(() => hosts.filter((h) => selected.has(String(h.server_id))), [hosts, selected]);
const needInstall = useMemo(() => selectedHosts.filter((h) => h.agent_status !== 'online'), [selectedHosts]);
const needUpgrade = useMemo(() => selectedHosts.filter((h) => h.agent_status === 'online'), [selectedHosts]);
// 全量概览基于接口完整结果,不受列表分页和当前排序影响。 // Logan.Li
const summary = useMemo(() => {
const online = hosts.filter((host) => host.agent_status === 'online');
const offline = hosts.filter((host) => host.agent_status === 'offline');
const notInstalled = hosts.filter((host) => host.agent_status === 'not_installed');
const average = (field: 'cpu_usage' | 'mem_usage') =>
online.length
? online.reduce((total, host) => total + Number(host[field] || 0), 0) / online.length
: 0;
return {
total: hosts.length,
online: online.length,
offline: offline.length,
notInstalled: notInstalled.length,
avgCPU: average('cpu_usage'),
avgMemory: average('mem_usage'),
};
}, [hosts]);
const openBatchDialog = () => {
setBatchAck(false);
@@ -338,6 +359,25 @@ export const AgentTab: React.FC = () => {
onSubmit={handleBatchSubmit}
/>
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3 mb-3">
{[
{ label: i18n.t('monitor.dashboard.agent.overview.totalHosts'), value: summary.total, icon: Server, tone: 'dg-text-blue' },
{ label: i18n.t('monitor.dashboard.agent.overview.online'), value: summary.online, icon: Wifi, tone: 'dg-text-green' },
{ label: i18n.t('monitor.dashboard.agent.overview.offline'), value: summary.offline, icon: WifiOff, tone: 'dg-text-orange' },
{ label: i18n.t('monitor.dashboard.agent.overview.notInstalled'), value: summary.notInstalled, icon: PackageOpen, tone: 'dg-text-yellow' },
{ label: i18n.t('monitor.dashboard.agent.overview.avgCpu'), value: `${summary.avgCPU.toFixed(1)}%`, icon: Cpu, tone: 'dg-text-blue' },
{ label: i18n.t('monitor.dashboard.agent.overview.avgMemory'), value: `${summary.avgMemory.toFixed(1)}%`, icon: HardDrive, tone: 'dg-text-purple' },
].map(({ label, value, icon: Icon, tone }) => (
<div key={label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5">
<div className="flex items-center justify-between gap-2 text-xs text-[var(--dg-text-secondary)]">
<span>{label}</span>
<Icon className={cn('h-3.5 w-3.5', tone)} />
</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-[var(--dg-text-primary)]">{value}</div>
</div>
))}
</div>
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)] border-b">
+8
View File
@@ -4110,6 +4110,14 @@
"batchUninstall": "Batch Uninstall",
"deploying": "Deploying",
"chart": "Chart",
"overview": {
"totalHosts": "Total Hosts",
"online": "Online",
"offline": "Offline",
"notInstalled": "Not Installed",
"avgCpu": "Average CPU",
"avgMemory": "Average Memory"
},
"toast": {
"installed": "Agent installation submitted, running in background…",
"installFailed": "Installation failed",
+8
View File
@@ -4110,6 +4110,14 @@
"batchUninstall": "批量卸载",
"deploying": "部署中",
"chart": "图表",
"overview": {
"totalHosts": "主机总数",
"online": "在线",
"offline": "离线",
"notInstalled": "未安装",
"avgCpu": "平均 CPU",
"avgMemory": "平均内存"
},
"toast": {
"installed": "Agent 安装任务已提交,后台执行中…",
"installFailed": "安装失败",