fix: 仪表盘资源趋势图使用真实Agent指标替代随机假数据
- 后端 GetMetrics 修复 strftime 字符串无法扫入 time.Time 导致 timestamp 为零值的 bug (改为 string 类型 + time.ParseInLocation 手动解析) - 前端 Dashboard.tsx 新增 getMetrics(24) 调用,获取真实按小时聚合的 Agent 数据 - DashboardCharts.tsx 新增 buildMetricsFromReal() 将 MetricData[] 转换为图表数据 - ResourceUsageCard 优先使用真实时间序列,无数据时回退到平均值生成
This commit is contained in:
1 parent
b770ec025b
commit
a3b84f79c7
3 files changed
+50
-8
No files matched your search
@@ -294,12 +294,12 @@ func (h *DashboardHandler) GetMetrics(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 agent_reports 表查最近 N 小时的真实数据
|
// 从 agent_reports 表查最近 N 小时的真实数据 // Logan.Li
|
||||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||||
type avgPoint struct {
|
type avgPoint struct {
|
||||||
Bucket time.Time
|
Bucket string // strftime 返回字符串 "2006-01-02 15:00",不能直接扫入 time.Time(会得零值)
|
||||||
AvgCPU float64
|
AvgCPU float64
|
||||||
AvgMem float64
|
AvgMem float64
|
||||||
}
|
}
|
||||||
var rows []avgPoint
|
var rows []avgPoint
|
||||||
h.db.Model(&models.AgentReport{}).
|
h.db.Model(&models.AgentReport{}).
|
||||||
@@ -311,9 +311,14 @@ func (h *DashboardHandler) GetMetrics(c *gin.Context) {
|
|||||||
|
|
||||||
var metrics []models.MetricData
|
var metrics []models.MetricData
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
// 手动解析 SQLite strftime 字符串为 time.Time(GORM 无法自动转换) // Logan.Li
|
||||||
|
ts, err := time.ParseInLocation("2006-01-02 15:00", r.Bucket, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
ts = time.Now()
|
||||||
|
}
|
||||||
metrics = append(metrics,
|
metrics = append(metrics,
|
||||||
models.MetricData{Name: "cpu", Value: r.AvgCPU, Timestamp: r.Bucket},
|
models.MetricData{Name: "cpu", Value: r.AvgCPU, Timestamp: ts},
|
||||||
models.MetricData{Name: "memory", Value: r.AvgMem, Timestamp: r.Bucket},
|
models.MetricData{Name: "memory", Value: r.AvgMem, Timestamp: ts},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ export function Dashboard() {
|
|||||||
const [alerts, setAlerts] = useState<any[]>([]);
|
const [alerts, setAlerts] = useState<any[]>([]);
|
||||||
const [releases, setReleases] = useState<any[]>([]);
|
const [releases, setReleases] = useState<any[]>([]);
|
||||||
const [expCerts, setExpCerts] = useState<any[]>([]);
|
const [expCerts, setExpCerts] = useState<any[]>([]);
|
||||||
|
const [metrics, setMetrics] = useState<any[]>([]); // 真实 Agent 指标时间序列 // Logan.Li
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statsRes, serversRes, clustersRes, alertsRes, releasesRes] = await Promise.all([
|
const [statsRes, serversRes, clustersRes, alertsRes, releasesRes, metricsRes] = await Promise.all([
|
||||||
dashboardStatsApi.getStats().catch((e) => {
|
dashboardStatsApi.getStats().catch((e) => {
|
||||||
console.error('Failed to fetch dashboard stats:', e);
|
console.error('Failed to fetch dashboard stats:', e);
|
||||||
return null;
|
return null;
|
||||||
@@ -46,6 +47,11 @@ export function Dashboard() {
|
|||||||
console.error('Failed to fetch dashboard releases:', e);
|
console.error('Failed to fetch dashboard releases:', e);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
|
// 获取真实 Agent 指标时间序列(过去24小时按小时聚合) // Logan.Li
|
||||||
|
dashboardStatsApi.getMetrics(24).catch((e) => {
|
||||||
|
console.error('Failed to fetch metrics:', e);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setStats(statsRes);
|
setStats(statsRes);
|
||||||
@@ -54,6 +60,7 @@ export function Dashboard() {
|
|||||||
setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
|
setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
|
||||||
setReleases(Array.isArray(releasesRes) ? releasesRes : []);
|
setReleases(Array.isArray(releasesRes) ? releasesRes : []);
|
||||||
setExpCerts(statsRes?.expiring_certs ?? []);
|
setExpCerts(statsRes?.expiring_certs ?? []);
|
||||||
|
setMetrics(Array.isArray(metricsRes) ? metricsRes : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard data:', error);
|
console.error('Failed to load dashboard data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -142,6 +149,7 @@ export function Dashboard() {
|
|||||||
avgCpu={Number(serverStats?.avg_cpu ?? 0)}
|
avgCpu={Number(serverStats?.avg_cpu ?? 0)}
|
||||||
avgMemory={Number(serverStats?.avg_memory ?? 0)}
|
avgMemory={Number(serverStats?.avg_memory ?? 0)}
|
||||||
avgDisk={Number(serverStats?.avg_disk ?? 0)}
|
avgDisk={Number(serverStats?.avg_disk ?? 0)}
|
||||||
|
metrics={metrics}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cost & Top Resource Usage */}
|
{/* Cost & Top Resource Usage */}
|
||||||
|
|||||||
@@ -248,17 +248,46 @@ export function TopResourceUsageCard({ topUsage }: { topUsage: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将后端 MetricData[] 时间序列转换为图表所需格式 { time, cpu, memory } // Logan.Li
|
||||||
|
function buildMetricsFromReal(metrics: any[], avgDisk: number): { time: string; cpu: number; memory: number; disk: number }[] {
|
||||||
|
if (!metrics || metrics.length === 0) return [];
|
||||||
|
// 按 timestamp 分组:同一时刻有 cpu + memory 两条记录
|
||||||
|
const buckets: Record<string, { cpu?: number; memory?: number }> = {};
|
||||||
|
for (const m of metrics) {
|
||||||
|
const ts = m.timestamp;
|
||||||
|
if (!ts) continue;
|
||||||
|
// 提取 HH:MM 格式作为 X 轴标签
|
||||||
|
const d = new Date(ts);
|
||||||
|
const label = `${d.getHours().toString().padStart(2, '0')}:00`;
|
||||||
|
if (!buckets[label]) buckets[label] = {};
|
||||||
|
if (m.name === 'cpu') buckets[label].cpu = Number(m.value) || 0;
|
||||||
|
if (m.name === 'memory') buckets[label].memory = Number(m.value) || 0;
|
||||||
|
}
|
||||||
|
return Object.entries(buckets)
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([time, v]) => ({
|
||||||
|
time,
|
||||||
|
cpu: Number((v.cpu ?? 0).toFixed(1)),
|
||||||
|
memory: Number((v.memory ?? 0).toFixed(1)),
|
||||||
|
disk: Number(avgDisk.toFixed(1)), // 磁盘无历史序列,用平均值
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// 资源使用率趋势卡片
|
// 资源使用率趋势卡片
|
||||||
export function ResourceUsageCard({
|
export function ResourceUsageCard({
|
||||||
avgCpu,
|
avgCpu,
|
||||||
avgMemory,
|
avgMemory,
|
||||||
avgDisk,
|
avgDisk,
|
||||||
|
metrics,
|
||||||
}: {
|
}: {
|
||||||
avgCpu: number;
|
avgCpu: number;
|
||||||
avgMemory: number;
|
avgMemory: number;
|
||||||
avgDisk: number;
|
avgDisk: number;
|
||||||
|
metrics?: any[];
|
||||||
}) {
|
}) {
|
||||||
const data = generateMetricsData(avgCpu, avgMemory, avgDisk, 24);
|
// 优先使用真实 Agent 指标时间序列;无数据时回退到平均值生成(带随机波动) // Logan.Li
|
||||||
|
const realData = buildMetricsFromReal(metrics ?? [], avgDisk);
|
||||||
|
const data = realData.length > 0 ? realData : generateMetricsData(avgCpu, avgMemory, avgDisk, 24);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
<Card className="bg-slate-900 border-slate-800">
|
||||||
|
|||||||
Reference in new issue
Block a user