deploy-ease-platform/frontend/src/pages/System/Metrics/Dashboard/index.tsx
dengqichen d9b8142028 1.45
2025-12-30 17:13:31 +08:00

774 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback } from 'react';
import {
Activity,
Cpu,
MemoryStick,
GitBranch,
Database,
RefreshCw,
Download,
AlertTriangle,
CheckCircle,
XCircle,
HelpCircle,
} from 'lucide-react';
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import {
LineChart,
Line,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import type { SystemMetrics, MetricHistoryPoint, ThresholdConfig } from './types';
import { getAllMetrics } from './service';
import { ThreadDetailDialog } from './components/ThreadDetailDialog';
// 告警阈值配置
const THRESHOLDS: ThresholdConfig = {
cpu: {
warning: 70,
danger: 90,
},
memory: {
warning: 75,
danger: 90,
},
threadBlocked: {
warning: 5,
danger: 10,
},
dbConnections: {
warning: 80,
danger: 95,
},
};
// 线程状态颜色
const THREAD_STATE_COLORS: Record<string, string> = {
RUNNABLE: '#10b981', // 绿色
WAITING: '#3b82f6', // 蓝色
TIMED_WAITING: '#8b5cf6', // 紫色
BLOCKED: '#ef4444', // 红色
NEW: '#6b7280', // 灰色
TERMINATED: '#9ca3af', // 浅灰色
};
const MetricsDashboard: React.FC = () => {
const { toast } = useToast();
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
const [loading, setLoading] = useState(false);
const [history, setHistory] = useState<MetricHistoryPoint[]>([]);
const [autoRefresh, setAutoRefresh] = useState(true);
const [threadDialogOpen, setThreadDialogOpen] = useState(false);
// 加载指标数据
const loadMetrics = useCallback(async (silent: boolean = false) => {
if (!silent) {
setLoading(true);
}
try {
const data = await getAllMetrics();
setMetrics(data);
// 添加到历史记录保留最近20个数据点
setHistory(prev => {
const newPoint = {
timestamp: data.timestamp,
heapUsed: data.memory.heapUsed / 1024 / 1024 / 1024, // 转换为 GB
heapMax: data.memory.heapMax / 1024 / 1024 / 1024,
cpuUsage: data.cpu.systemCpu * 100,
};
// 检查是否有旧数据MB单位如果有则清空重新收集
if (prev.length > 0 && prev[0].heapUsed > 100) {
// 旧数据是 MB 单位(通常 > 100清空
return [newPoint];
}
const newHistory = [...prev, newPoint];
return newHistory.slice(-20); // 只保留最近20个点
});
// 检查是否有数据异常(部分接口失败)
if (!silent) {
const hasIssues =
data.health.status === 'UNKNOWN' ||
data.memory.heapMax === 0 ||
data.cpu.systemCpu === 0;
if (hasIssues) {
toast({
title: '部分指标获取失败',
description: '某些监控指标无法获取,显示的可能是默认值',
variant: 'default',
});
}
}
} catch (error: any) {
console.error('加载指标失败:', error);
toast({
variant: 'destructive',
title: '加载失败',
description: error.response?.data?.message || '无法获取系统指标数据,请检查后端服务和 actuator 配置',
});
} finally {
if (!silent) {
setLoading(false);
}
}
}, [toast]);
// 初始加载
useEffect(() => {
loadMetrics();
}, [loadMetrics]);
// 自动刷新30秒
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
loadMetrics(true); // 静默刷新
}, 30000); // 30秒
return () => clearInterval(interval);
}, [autoRefresh, loadMetrics]);
// 格式化字节为 GB统一单位
// 处理 JVM 返回 -1 表示"无限制"的情况
const formatBytes = (bytes: number, showUnlimited: boolean = false): string => {
if (bytes < 0) {
return showUnlimited ? '无限制' : '-';
}
const gb = bytes / 1024 / 1024 / 1024;
return `${gb.toFixed(2)} GB`;
};
// 计算内存使用百分比(处理 max 为 -1 或 0 的情况)
const calcMemoryPercent = (used: number, max: number, committed?: number): number => {
if (max > 0) {
return (used / max) * 100;
}
// 如果 max 无效,使用 committed 作为参考
if (committed && committed > 0) {
return (used / committed) * 100;
}
return 0;
};
// 格式化百分比
const formatPercent = (value: number): string => {
return value.toFixed(2);
};
// 获取状态颜色
const getStatusColor = (value: number, threshold: { warning: number; danger: number }) => {
if (value >= threshold.danger) return 'text-red-600 dark:text-red-400';
if (value >= threshold.warning) return 'text-yellow-600 dark:text-yellow-400';
return 'text-green-600 dark:text-green-400';
};
// 获取健康状态图标
const getHealthIcon = (status: string) => {
switch (status) {
case 'UP':
return <CheckCircle className="h-5 w-5 text-green-600" />;
case 'DOWN':
return <XCircle className="h-5 w-5 text-red-600" />;
default:
return <HelpCircle className="h-5 w-5 text-gray-600" />;
}
};
// 导出数据
const handleExport = () => {
if (!metrics) return;
const data = {
timestamp: new Date(metrics.timestamp).toLocaleString('zh-CN'),
health: metrics.health.status,
cpu: {
system: formatPercent(metrics.cpu.systemCpu * 100) + '%',
process: formatPercent(metrics.cpu.processCpu * 100) + '%',
},
memory: {
heap: {
used: formatBytes(metrics.memory.heapUsed),
max: formatBytes(metrics.memory.heapMax),
committed: formatBytes(metrics.memory.heapCommitted),
percent: formatPercent(metrics.memory.heapPercent) + '%',
},
nonHeap: {
used: formatBytes(metrics.memory.nonHeapUsed),
max: formatBytes(metrics.memory.nonHeapMax),
committed: formatBytes(metrics.memory.nonHeapCommitted),
percent: formatPercent(metrics.memory.nonHeapPercent) + '%',
},
regions: metrics.memory.regions ? {
eden: metrics.memory.regions.eden ? {
used: formatBytes(metrics.memory.regions.eden.used),
max: formatBytes(metrics.memory.regions.eden.max, true),
committed: formatBytes(metrics.memory.regions.eden.committed),
} : null,
oldGen: metrics.memory.regions.oldGen ? {
used: formatBytes(metrics.memory.regions.oldGen.used),
max: formatBytes(metrics.memory.regions.oldGen.max, true),
committed: formatBytes(metrics.memory.regions.oldGen.committed),
} : null,
survivor: metrics.memory.regions.survivor ? {
used: formatBytes(metrics.memory.regions.survivor.used),
max: formatBytes(metrics.memory.regions.survivor.max, true),
committed: formatBytes(metrics.memory.regions.survivor.committed),
} : null,
metaspace: metrics.memory.regions.metaspace ? {
used: formatBytes(metrics.memory.regions.metaspace.used),
max: formatBytes(metrics.memory.regions.metaspace.max, true),
committed: formatBytes(metrics.memory.regions.metaspace.committed),
} : null,
} : null,
},
threads: metrics.threads,
hikari: metrics.hikari,
gc: metrics.gc,
threadStates: metrics.threadStates,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `system-metrics-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
toast({
title: '导出成功',
description: '系统指标数据已导出',
});
};
// 准备线程状态饼图数据
const threadStateData = metrics
? Object.entries(metrics.threadStates)
.filter(([_, value]) => value > 0)
.map(([name, value]) => ({
name,
value,
}))
: [];
if (loading && !metrics) {
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
{[1, 2, 3, 4].map(i => (
<Card key={i}>
<CardContent className="p-6">
<Skeleton className="h-20" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
if (!metrics) {
return null;
}
const cpuPercent = metrics.cpu.systemCpu * 100;
const memoryPercent = metrics.memory.heapPercent;
const blockedThreads = metrics.threadStates.BLOCKED || 0;
const dbUsagePercent = metrics.hikari.usagePercent;
return (
<div className="p-6 space-y-6">
{/* 标题栏 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1">
·
{autoRefresh && <span className="text-green-600 ml-2"> (30)</span>}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
>
{autoRefresh ? '停止刷新' : '开启刷新'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => loadMetrics()}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-4">
{/* 健康状态 */}
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border-blue-200 dark:border-blue-900">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
{getHealthIcon(metrics.health.status)}
<span className="text-xs font-medium text-muted-foreground"></span>
</div>
<div className="text-2xl font-bold">
{metrics.health.status === 'UP' ? '正常' :
metrics.health.status === 'DOWN' ? '异常' : '未知'}
</div>
<Badge
variant={metrics.health.status === 'UP' ? 'default' : 'destructive'}
>
{metrics.health.status}
</Badge>
</div>
<Activity className="h-10 w-10 text-blue-600 dark:text-blue-400 opacity-50" />
</div>
</CardContent>
</Card>
{/* CPU 使用率 */}
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200 dark:border-green-900">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4" />
<span className="text-xs font-medium text-muted-foreground">CPU 使</span>
</div>
<div className={`text-2xl font-bold ${getStatusColor(cpuPercent, THRESHOLDS.cpu)}`}>
{formatPercent(cpuPercent)}%
</div>
<Progress value={cpuPercent} className="h-2" />
{cpuPercent >= THRESHOLDS.cpu.warning && (
<div className="flex items-center gap-1 text-xs text-yellow-600">
<AlertTriangle className="h-3 w-3" />
CPU 使
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* 内存使用 */}
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-purple-200 dark:border-purple-900">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2">
<MemoryStick className="h-4 w-4" />
<span className="text-xs font-medium text-muted-foreground">JVM </span>
</div>
<div className={`text-2xl font-bold ${getStatusColor(memoryPercent, THRESHOLDS.memory)}`}>
{formatBytes(metrics.memory.heapUsed)}
</div>
<div className="text-xs text-muted-foreground space-y-0.5">
<div>: {formatBytes(metrics.memory.heapMax)}</div>
<div>: {formatBytes(metrics.memory.heapCommitted)}</div>
</div>
<Progress value={memoryPercent} className="h-2" />
{memoryPercent >= THRESHOLDS.memory.warning && (
<div className="flex items-center gap-1 text-xs text-yellow-600">
<AlertTriangle className="h-3 w-3" />
使
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* 线程数 */}
<Card
className="bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20 border-orange-200 dark:border-orange-900 cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => setThreadDialogOpen(true)}
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
<span className="text-xs font-medium text-muted-foreground">线</span>
<span className="text-xs text-blue-600 dark:text-blue-400"></span>
</div>
<div className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{metrics.threads.live}
</div>
<div className="text-xs text-muted-foreground space-y-0.5">
<div>线: {metrics.threads.daemon}</div>
<div>: {metrics.threads.peak}</div>
</div>
{blockedThreads >= THRESHOLDS.threadBlocked.warning && (
<div className="flex items-center gap-1 text-xs text-red-600">
<AlertTriangle className="h-3 w-3" />
{blockedThreads} 线
</div>
)}
</div>
<GitBranch className="h-10 w-10 text-orange-600 dark:text-orange-400 opacity-50" />
</div>
</CardContent>
</Card>
</div>
{/* JVM 内存趋势图 */}
<Card>
<CardHeader>
<CardTitle>JVM </CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(value) =>
new Date(value).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
/>
<YAxis
tickFormatter={(value: number) => `${value.toFixed(1)}G`}
/>
<Tooltip
labelFormatter={(value) =>
new Date(value).toLocaleTimeString('zh-CN')
}
formatter={(value: number) => `${value.toFixed(2)} GB`}
/>
<Legend />
<Line
type="monotone"
dataKey="heapUsed"
stroke="#8b5cf6"
strokeWidth={2}
name="堆内存使用"
dot={false}
/>
<Line
type="monotone"
dataKey="heapMax"
stroke="#94a3b8"
strokeWidth={1}
strokeDasharray="5 5"
name="堆内存最大值"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* 数据库连接池 & 线程状态 */}
<div className="grid gap-4 md:grid-cols-2">
{/* 数据库连接池 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-green-600">
{metrics.hikari.active}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-blue-600">
{metrics.hikari.idle}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-orange-600">
{metrics.hikari.pending}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-gray-600">
{metrics.hikari.max}
</div>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">使</span>
<span className={`text-sm font-bold ${getStatusColor(dbUsagePercent, THRESHOLDS.dbConnections)}`}>
{formatPercent(dbUsagePercent)}%
</span>
</div>
<Progress value={dbUsagePercent} className="h-3" />
<div className="text-xs text-muted-foreground mt-2">
: {metrics.hikari.total} / {metrics.hikari.max}
</div>
{dbUsagePercent >= THRESHOLDS.dbConnections.warning && (
<div className="flex items-center gap-1 text-xs text-yellow-600 mt-2">
<AlertTriangle className="h-3 w-3" />
使
</div>
)}
</div>
</CardContent>
</Card>
{/* 线程状态分布 */}
<Card>
<CardHeader>
<CardTitle>线</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={threadStateData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{threadStateData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={THREAD_STATE_COLORS[entry.name] || '#6b7280'}
/>
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
{blockedThreads >= THRESHOLDS.threadBlocked.danger && (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 dark:bg-red-950/20 p-3 rounded-lg mt-4">
<AlertTriangle className="h-4 w-4" />
<span> {blockedThreads} 线</span>
</div>
)}
</CardContent>
</Card>
</div>
{/* 内存区域详情(如果可用) */}
{metrics.memory.regions && (
<Card>
<CardHeader>
<CardTitle>JVM </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Eden 区 */}
{metrics.memory.regions.eden && (
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 rounded-lg border border-green-200 dark:border-green-900">
<div className="text-sm font-medium text-muted-foreground mb-2">Eden </div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">使:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.eden.used)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.eden.committed)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.eden.max, true)}</span>
</div>
<Progress
value={calcMemoryPercent(
metrics.memory.regions.eden.used,
metrics.memory.regions.eden.max,
metrics.memory.regions.eden.committed
)}
className="h-1 mt-2"
/>
</div>
</div>
)}
{/* Old Gen */}
{metrics.memory.regions.oldGen && (
<div className="p-4 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 rounded-lg border border-blue-200 dark:border-blue-900">
<div className="text-sm font-medium text-muted-foreground mb-2">Old Gen</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">使:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.oldGen.used)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.oldGen.committed)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.oldGen.max, true)}</span>
</div>
<Progress
value={calcMemoryPercent(
metrics.memory.regions.oldGen.used,
metrics.memory.regions.oldGen.max,
metrics.memory.regions.oldGen.committed
)}
className="h-1 mt-2"
/>
</div>
</div>
)}
{/* Survivor 区 */}
{metrics.memory.regions.survivor && (
<div className="p-4 bg-gradient-to-br from-yellow-50 to-orange-50 dark:from-yellow-950/20 dark:to-orange-950/20 rounded-lg border border-yellow-200 dark:border-yellow-900">
<div className="text-sm font-medium text-muted-foreground mb-2">Survivor </div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">使:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.survivor.used)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.survivor.committed)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.survivor.max, true)}</span>
</div>
<Progress
value={calcMemoryPercent(
metrics.memory.regions.survivor.used,
metrics.memory.regions.survivor.max,
metrics.memory.regions.survivor.committed
)}
className="h-1 mt-2"
/>
</div>
</div>
)}
{/* Metaspace */}
{metrics.memory.regions.metaspace && (
<div className="p-4 bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 rounded-lg border border-purple-200 dark:border-purple-900">
<div className="text-sm font-medium text-muted-foreground mb-2">Metaspace</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">使:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.metaspace.used)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.metaspace.committed)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{formatBytes(metrics.memory.regions.metaspace.max, true)}</span>
</div>
<Progress
value={calcMemoryPercent(
metrics.memory.regions.metaspace.used,
metrics.memory.regions.metaspace.max,
metrics.memory.regions.metaspace.committed
)}
className="h-1 mt-2"
/>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* GC 统计 */}
<Card>
<CardHeader>
<CardTitle> (GC) </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1">GC </div>
<div className="text-2xl font-bold">{metrics.gc.count}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold">{metrics.gc.totalTime.toFixed(3)}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold">{metrics.gc.avgTime.toFixed(2)}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold">{(metrics.gc.maxTime * 1000).toFixed(2)}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
</CardContent>
</Card>
{/* 底部时间戳 */}
<div className="text-xs text-muted-foreground text-center">
: {new Date(metrics.timestamp).toLocaleString('zh-CN')}
</div>
{/* 线程详情对话框 */}
<ThreadDetailDialog
open={threadDialogOpen}
onOpenChange={setThreadDialogOpen}
/>
</div>
);
};
export default MetricsDashboard;