增加系统版本维护页面

This commit is contained in:
dengqichen 2025-12-09 18:06:48 +08:00
parent a0d38e1d00
commit f77f01689f
5 changed files with 1596 additions and 0 deletions

View File

@ -0,0 +1,505 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogBody,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/ui/use-toast';
import { Search, Copy, ArrowUpDown, AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
import type { ThreadDetail } from '../types';
import { getThreadDump } from '../service';
interface ThreadDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
type SortField = 'id' | 'blocked' | 'waited';
type SortOrder = 'asc' | 'desc';
// 线程状态颜色映射
const getStateColor = (state: string) => {
switch (state) {
case 'RUNNABLE':
return 'default';
case 'WAITING':
return 'secondary';
case 'TIMED_WAITING':
return 'outline';
case 'BLOCKED':
return 'destructive';
case 'NEW':
return 'secondary';
case 'TERMINATED':
return 'outline';
default:
return 'secondary';
}
};
export const ThreadDetailDialog: React.FC<ThreadDetailDialogProps> = ({
open,
onOpenChange,
}) => {
const { toast } = useToast();
const [threads, setThreads] = useState<ThreadDetail[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [selectedState, setSelectedState] = useState<string | null>(null);
const [sortField, setSortField] = useState<SortField>('id');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [expandedThreads, setExpandedThreads] = useState<Set<number>>(new Set());
const [showProblemsOnly, setShowProblemsOnly] = useState(false);
// 加载线程详情
useEffect(() => {
if (open) {
loadThreads();
}
}, [open]);
const loadThreads = async () => {
setLoading(true);
try {
const data = await getThreadDump();
setThreads(data.threads || []);
} catch (error) {
console.error('加载线程详情失败:', error);
} finally {
setLoading(false);
}
};
// 过滤和排序线程
const filteredThreads = threads
.filter(thread => {
// 仅显示问题线程
if (showProblemsOnly) {
const hasProblems =
thread.threadState === 'BLOCKED' ||
thread.blockedCount > 5 ||
thread.waitedCount > 100;
if (!hasProblems) return false;
}
// 状态筛选
if (selectedState && thread.threadState !== selectedState) {
return false;
}
// 关键词搜索
if (!searchKeyword.trim()) return true;
const keyword = searchKeyword.toLowerCase();
return (
thread.threadName.toLowerCase().includes(keyword) ||
thread.threadState.toLowerCase().includes(keyword)
);
})
.sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'blocked':
comparison = a.blockedCount - b.blockedCount;
break;
case 'waited':
comparison = a.waitedCount - b.waitedCount;
break;
case 'id':
default:
comparison = a.threadId - b.threadId;
break;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
// 按状态分组统计
const stateStats = threads.reduce((acc, thread) => {
acc[thread.threadState] = (acc[thread.threadState] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// 复制线程详情(当前筛选的线程 + 完整堆栈)
const handleCopyThreadDetails = () => {
const details = filteredThreads.map(thread => {
// 格式化堆栈信息
const stackTrace = thread.stackTrace && thread.stackTrace.length > 0
? '\n堆栈信息:\n' + thread.stackTrace
.map(s => ` at ${s.className}.${s.methodName}(${s.fileName}:${s.lineNumber})`)
.join('\n')
: '\n堆栈信息: 无';
return `
线程ID: ${thread.threadId}
线程名称: ${thread.threadName}
线程状态: ${thread.threadState}
阻塞次数: ${thread.blockedCount}
阻塞时间: ${thread.blockedTime}ms
等待次数: ${thread.waitedCount}
等待时间: ${thread.waitedTime}ms
锁信息: ${thread.lockInfo ? '持有锁' : '无'}${stackTrace}
----------------------------------------`;
}).join('\n');
// 当前筛选条件说明
let filterInfo = '';
if (selectedState) {
filterInfo += `\n筛选条件: 状态=${selectedState}`;
}
if (showProblemsOnly) {
filterInfo += `\n筛选条件: 仅显示问题线程BLOCKED 或 阻塞>5 或 等待>100`;
}
if (searchKeyword) {
filterInfo += `\n搜索关键词: ${searchKeyword}`;
}
if (sortField !== 'id') {
filterInfo += `\n排序方式: ${sortField === 'blocked' ? '阻塞次数' : '等待次数'} (${sortOrder === 'desc' ? '降序' : '升序'})`;
}
const summary = `
=====================================
线
=====================================
导出时间: ${new Date().toLocaleString('zh-CN')}
总线程数: ${threads.length}
显示线程数: ${filteredThreads.length} ${filterInfo}
=====================================
:
${Object.entries(stateStats).map(([state, count]) => ` ${state}: ${count}`).join('\n')}
=====================================
${details}
=====================================
=====================================
`;
navigator.clipboard.writeText(summary).then(() => {
toast({
title: '✓ 复制成功',
description: `已复制 ${filteredThreads.length} 个线程的详细信息(含堆栈)`,
});
}).catch(() => {
toast({
variant: 'destructive',
title: '复制失败',
description: '无法复制到剪贴板',
});
});
};
// 点击状态标签进行筛选
const handleStateClick = (state: string) => {
if (selectedState === state) {
setSelectedState(null);
} else {
setSelectedState(state);
}
};
// 切换排序
const handleSort = (field: SortField) => {
if (sortField === field) {
// 切换排序方向
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
// 新字段,默认降序(问题线程在前)
setSortField(field);
setSortOrder('desc');
}
};
// 展开/折叠线程堆栈
const toggleThreadExpand = (threadId: number) => {
const newExpanded = new Set(expandedThreads);
if (newExpanded.has(threadId)) {
newExpanded.delete(threadId);
} else {
newExpanded.add(threadId);
}
setExpandedThreads(newExpanded);
};
// 判断是否为问题线程
const isProblemThread = (thread: ThreadDetail) => {
return thread.threadState === 'BLOCKED' ||
thread.blockedCount > 5 ||
thread.waitedCount > 100;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle>
<div className="flex items-center justify-between">
<span>线 ({threads.length} 线)</span>
<div className="flex items-center gap-3">
{!loading && (
<div className="flex items-center gap-2 text-sm font-normal">
{Object.entries(stateStats).map(([state, count]) => (
<Badge
key={state}
variant={getStateColor(state)}
className={`cursor-pointer transition-all ${
selectedState === state
? 'ring-2 ring-offset-2 ring-primary'
: 'hover:opacity-80'
}`}
onClick={() => handleStateClick(state)}
>
{state}: {count}
</Badge>
))}
</div>
)}
<Button
variant={showProblemsOnly ? 'default' : 'outline'}
size="sm"
onClick={() => setShowProblemsOnly(!showProblemsOnly)}
disabled={loading}
>
<AlertTriangle className="h-4 w-4 mr-2" />
{showProblemsOnly ? '显示全部' : '仅问题线程'}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCopyThreadDetails}
disabled={loading || threads.length === 0}
>
<Copy className="h-4 w-4 mr-2" />
线
</Button>
</div>
</div>
</DialogTitle>
</DialogHeader>
<DialogBody className="space-y-4">
{/* 搜索框 */}
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索线程名称或状态..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="pl-10"
/>
</div>
{selectedState && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground"></span>
<Badge variant={getStateColor(selectedState)}>
{selectedState}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedState(null)}
className="h-6 px-2 text-xs"
>
</Button>
</div>
)}
</div>
{/* 线程列表 */}
{loading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map(i => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]"></TableHead>
<TableHead
className="w-[50px] cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('id')}
>
<div className="flex items-center gap-1">
ID
{sortField === 'id' && <ArrowUpDown className="h-3 w-3" />}
</div>
</TableHead>
<TableHead className="w-[200px]">线</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead
className="w-[90px] cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('blocked')}
>
<div className="flex items-center gap-1 text-xs">
{sortField === 'blocked' && <ArrowUpDown className="h-3 w-3" />}
</div>
</TableHead>
<TableHead
className="w-[90px] cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('waited')}
>
<div className="flex items-center gap-1 text-xs">
{sortField === 'waited' && <ArrowUpDown className="h-3 w-3" />}
</div>
</TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredThreads.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
{searchKeyword ? '未找到匹配的线程' : '暂无线程数据'}
</TableCell>
</TableRow>
) : (
filteredThreads.map((thread) => {
const isExpanded = expandedThreads.has(thread.threadId);
const hasProblem = isProblemThread(thread);
return (
<React.Fragment key={thread.threadId}>
<TableRow className={hasProblem ? 'bg-red-50 dark:bg-red-950/10' : ''}>
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => toggleThreadExpand(thread.threadId)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</TableCell>
<TableCell className="font-mono text-xs">
{thread.threadId}
</TableCell>
<TableCell className="font-mono text-xs max-w-[200px]">
<div className="flex items-center gap-1.5">
{hasProblem && (
<AlertTriangle className="h-3.5 w-3.5 text-red-600 flex-shrink-0" />
)}
<span className="truncate" title={thread.threadName}>
{thread.threadName}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={getStateColor(thread.threadState)}>
{thread.threadState}
</Badge>
</TableCell>
<TableCell className="text-center">
{thread.blockedCount > 0 ? (
<span className={`font-medium ${
thread.blockedCount > 5
? 'text-red-600'
: 'text-orange-600'
}`}>
{thread.blockedCount}
</span>
) : (
<span className="text-muted-foreground">0</span>
)}
</TableCell>
<TableCell className="text-center">
<span className={thread.waitedCount > 100 ? 'text-orange-600 font-medium' : 'text-muted-foreground'}>
{thread.waitedCount}
</span>
</TableCell>
<TableCell className="text-xs">
{thread.lockInfo ? (
<span className="text-orange-600"></span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
{isExpanded && (
<TableRow>
<TableCell colSpan={7} className="bg-muted/30 p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">线</h4>
<Button
variant="outline"
size="sm"
onClick={() => {
const stack = thread.stackTrace
.map(s => ` at ${s.className}.${s.methodName}(${s.fileName}:${s.lineNumber})`)
.join('\n');
navigator.clipboard.writeText(`线程 #${thread.threadId}: ${thread.threadName}\n${stack}`);
toast({ title: '已复制堆栈信息' });
}}
>
<Copy className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="bg-background rounded border p-3 font-mono text-xs overflow-x-auto max-h-64 overflow-y-auto">
{thread.stackTrace && thread.stackTrace.length > 0 ? (
thread.stackTrace.map((stack, idx) => (
<div
key={idx}
className={`py-0.5 ${
stack.className?.includes('Lock') ||
stack.methodName?.includes('lock') ||
stack.methodName?.includes('wait')
? 'text-orange-600 font-medium'
: 'text-muted-foreground'
}`}
>
at {stack.className}.{stack.methodName}
({stack.fileName}:{stack.lineNumber})
</div>
))
) : (
<div className="text-muted-foreground"></div>
)}
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
)}
{/* 底部统计 */}
<div className="text-xs text-muted-foreground text-center">
{filteredThreads.length} / {threads.length} 线
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,597 @@
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统一单位
const formatBytes = (bytes: number): string => {
const gb = bytes / 1024 / 1024 / 1024;
return `${gb.toFixed(2)} GB`;
};
// 格式化百分比
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: {
heapUsed: formatBytes(metrics.memory.heapUsed),
heapMax: formatBytes(metrics.memory.heapMax),
heapPercent: formatPercent(metrics.memory.heapPercent) + '%',
},
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">
/ {formatBytes(metrics.memory.heapMax)}
</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>
{/* 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;

View File

@ -0,0 +1,297 @@
import type {
HealthResponse,
MetricResponse,
ThreadDumpResponse,
JvmMemoryInfo,
CpuUsageInfo,
ThreadInfo,
HikariInfo,
GcInfo,
ThreadStateCount,
SystemMetrics,
} from './types';
// API 基础路径
const ACTUATOR_BASE_URL = '/actuator';
/**
* fetch actuator 使 request
*/
const fetchActuator = async <T>(url: string): Promise<T> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
/**
*
*/
export const getHealth = () =>
fetchActuator<HealthResponse>(`${ACTUATOR_BASE_URL}/health`);
/**
*
*/
export const getMetric = (metricName: string, tag?: string) => {
const url = tag
? `${ACTUATOR_BASE_URL}/metrics/${metricName}?tag=${encodeURIComponent(tag)}`
: `${ACTUATOR_BASE_URL}/metrics/${metricName}`;
return fetchActuator<MetricResponse>(url);
};
/**
* JVM
*/
export const getJvmMemory = async (): Promise<JvmMemoryInfo> => {
const [heapUsedRes, heapMaxRes, nonHeapUsedRes, nonHeapMaxRes] = await Promise.all([
getMetric('jvm.memory.used', 'area:heap'),
getMetric('jvm.memory.max', 'area:heap'),
getMetric('jvm.memory.used', 'area:nonheap'),
getMetric('jvm.memory.max', 'area:nonheap'),
]);
const heapUsed = heapUsedRes.measurements[0].value;
const heapMax = heapMaxRes.measurements[0].value;
const nonHeapUsed = nonHeapUsedRes.measurements[0].value;
const nonHeapMax = nonHeapMaxRes.measurements[0].value;
return {
heapUsed,
heapMax,
heapPercent: (heapUsed / heapMax) * 100,
nonHeapUsed,
nonHeapMax,
nonHeapPercent: (nonHeapUsed / nonHeapMax) * 100,
};
};
/**
* CPU 使
*/
export const getCpuUsage = async (): Promise<CpuUsageInfo> => {
const [systemCpuRes, processCpuRes] = await Promise.all([
getMetric('system.cpu.usage'),
getMetric('process.cpu.usage'),
]);
return {
systemCpu: systemCpuRes.measurements[0].value,
processCpu: processCpuRes.measurements[0].value,
};
};
/**
* 线
*/
export const getThreadInfo = async (): Promise<ThreadInfo> => {
const [liveRes, daemonRes, peakRes] = await Promise.all([
getMetric('jvm.threads.live'),
getMetric('jvm.threads.daemon'),
getMetric('jvm.threads.peak'),
]);
return {
live: liveRes.measurements[0].value,
daemon: daemonRes.measurements[0].value,
peak: peakRes.measurements[0].value,
};
};
/**
* HikariCP
*/
export const getHikariInfo = async (): Promise<HikariInfo> => {
try {
// 注意:根据 Spring Boot 版本不同metric 名称可能是 hikaricp 或 hikari
// 这里尝试 hikaricp如果失败会降级处理
const [activeRes, idleRes, maxRes, minRes, pendingRes] = await Promise.all([
getMetric('hikaricp.connections.active'),
getMetric('hikaricp.connections.idle'),
getMetric('hikaricp.connections.max'),
getMetric('hikaricp.connections.min'),
getMetric('hikaricp.connections.pending'),
]);
const active = activeRes.measurements[0].value;
const idle = idleRes.measurements[0].value;
const max = maxRes.measurements[0].value;
const min = minRes.measurements[0].value;
const pending = pendingRes.measurements[0].value;
const total = active + idle;
return {
active,
idle,
max,
min,
pending,
total,
usagePercent: (total / max) * 100,
};
} catch (error) {
// 如果 HikariCP 指标不可用,返回默认值
return {
active: 0,
idle: 0,
max: 0,
min: 0,
pending: 0,
total: 0,
usagePercent: 0,
};
}
};
/**
* GC
*/
export const getGcInfo = async (): Promise<GcInfo> => {
try {
// 先获取不带 tag 的 GC 指标,获取所有 GC 暂停时间的汇总数据
const gcRes = await getMetric('jvm.gc.pause');
const countMeasurement = gcRes.measurements.find(m => m.statistic === 'COUNT');
const totalTimeMeasurement = gcRes.measurements.find(m => m.statistic === 'TOTAL_TIME');
const maxMeasurement = gcRes.measurements.find(m => m.statistic === 'MAX');
const count = countMeasurement?.value || 0;
const totalTime = totalTimeMeasurement?.value || 0;
const maxTime = maxMeasurement?.value || 0;
const avgTime = count > 0 ? (totalTime / count) * 1000 : 0; // 转换为毫秒
return {
count,
totalTime,
maxTime,
avgTime,
};
} catch (error) {
console.warn('GC 指标获取失败,使用默认值:', error);
// 如果 GC 指标不可用,返回默认值
return {
count: 0,
totalTime: 0,
maxTime: 0,
avgTime: 0,
};
}
};
/**
* 线
*/
export const getThreadDump = () =>
fetchActuator<ThreadDumpResponse>(`${ACTUATOR_BASE_URL}/threaddump`);
/**
* 线
*/
export const getThreadStateCount = async (): Promise<ThreadStateCount> => {
const threadDump = await getThreadDump();
const stateCount: ThreadStateCount = {
RUNNABLE: 0,
WAITING: 0,
TIMED_WAITING: 0,
BLOCKED: 0,
NEW: 0,
TERMINATED: 0,
};
threadDump.threads.forEach(thread => {
if (stateCount.hasOwnProperty(thread.threadState)) {
stateCount[thread.threadState]++;
}
});
return stateCount;
};
/**
*
* 使 Promise.allSettled
*/
export const getAllMetrics = async (): Promise<SystemMetrics> => {
const results = await Promise.allSettled([
getHealth(),
getJvmMemory(),
getCpuUsage(),
getThreadInfo(),
getHikariInfo(),
getGcInfo(),
getThreadStateCount(),
]);
// 提取成功的结果,失败的使用默认值
const [healthResult, memoryResult, cpuResult, threadsResult, hikariResult, gcResult, threadStatesResult] = results;
return {
health: healthResult.status === 'fulfilled' ? healthResult.value : { status: 'UNKNOWN' },
memory: memoryResult.status === 'fulfilled' ? memoryResult.value : {
heapUsed: 0,
heapMax: 0,
heapPercent: 0,
nonHeapUsed: 0,
nonHeapMax: 0,
nonHeapPercent: 0,
},
cpu: cpuResult.status === 'fulfilled' ? cpuResult.value : { systemCpu: 0, processCpu: 0 },
threads: threadsResult.status === 'fulfilled' ? threadsResult.value : { live: 0, daemon: 0, peak: 0 },
hikari: hikariResult.status === 'fulfilled' ? hikariResult.value : {
active: 0,
idle: 0,
max: 0,
min: 0,
pending: 0,
total: 0,
usagePercent: 0,
},
gc: gcResult.status === 'fulfilled' ? gcResult.value : {
count: 0,
totalTime: 0,
maxTime: 0,
avgTime: 0,
},
threadStates: threadStatesResult.status === 'fulfilled' ? threadStatesResult.value : {
RUNNABLE: 0,
WAITING: 0,
TIMED_WAITING: 0,
BLOCKED: 0,
NEW: 0,
TERMINATED: 0,
},
timestamp: Date.now(),
};
};
/**
*
*/
export const updateLogLevel = async (loggerName: string, level: 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR') => {
const response = await fetch(`${ACTUATOR_BASE_URL}/loggers/${loggerName}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
configuredLevel: level,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
};
/**
*
*/
export const getLoggers = () =>
fetchActuator(`${ACTUATOR_BASE_URL}/loggers`);
/**
*
*/
export const getEnv = () =>
fetchActuator(`${ACTUATOR_BASE_URL}/env`);

View File

@ -0,0 +1,184 @@
/**
*
*/
/**
*
*/
export interface HealthResponse {
status: 'UP' | 'DOWN' | 'UNKNOWN';
components?: {
db?: {
status: string;
details?: Record<string, any>;
};
diskSpace?: {
status: string;
details?: {
total: number;
free: number;
threshold: number;
};
};
ping?: {
status: string;
};
[key: string]: any;
};
}
/**
* Metrics
*/
export interface Measurement {
statistic: 'VALUE' | 'COUNT' | 'TOTAL_TIME' | 'MAX' | 'TOTAL';
value: number;
}
/**
* Metrics
*/
export interface MetricResponse {
name: string;
description?: string;
baseUnit?: string;
measurements: Measurement[];
availableTags?: Array<{
tag: string;
values: string[];
}>;
}
/**
* JVM
*/
export interface JvmMemoryInfo {
heapUsed: number; // 堆内存使用量(字节)
heapMax: number; // 堆内存最大值(字节)
heapPercent: number; // 堆内存使用百分比
nonHeapUsed: number; // 非堆内存使用量(字节)
nonHeapMax: number; // 非堆内存最大值(字节)
nonHeapPercent: number; // 非堆内存使用百分比
}
/**
* CPU 使
*/
export interface CpuUsageInfo {
systemCpu: number; // 系统CPU使用率 (0-1)
processCpu: number; // 进程CPU使用率 (0-1)
}
/**
* 线
*/
export interface ThreadInfo {
live: number; // 活跃线程数
daemon: number; // 守护线程数
peak: number; // 峰值线程数
}
/**
*
*/
export interface HikariInfo {
active: number; // 活跃连接
idle: number; // 空闲连接
max: number; // 最大连接
min: number; // 最小连接
pending: number; // 等待连接
total: number; // 总连接数
usagePercent: number; // 使用率
}
/**
* GC
*/
export interface GcInfo {
count: number; // GC 次数
totalTime: number; // GC 总耗时(秒)
maxTime: number; // 最长GC时间
avgTime: number; // 平均GC时间毫秒
}
/**
* 线
*/
export interface ThreadDetail {
threadName: string;
threadId: number;
threadState: 'RUNNABLE' | 'WAITING' | 'TIMED_WAITING' | 'BLOCKED' | 'NEW' | 'TERMINATED';
blockedTime: number;
blockedCount: number;
waitedTime: number;
waitedCount: number;
lockInfo: any;
lockedMonitors: any[];
lockedSynchronizers: any[];
stackTrace: any[];
}
/**
* 线
*/
export interface ThreadDumpResponse {
threads: ThreadDetail[];
}
/**
* 线
*/
export interface ThreadStateCount {
RUNNABLE: number;
WAITING: number;
TIMED_WAITING: number;
BLOCKED: number;
NEW: number;
TERMINATED: number;
}
/**
*
*/
export interface SystemMetrics {
health: HealthResponse;
memory: JvmMemoryInfo;
cpu: CpuUsageInfo;
threads: ThreadInfo;
hikari: HikariInfo;
gc: GcInfo;
threadStates: ThreadStateCount;
timestamp: number; // 采集时间戳
}
/**
*
*/
export interface ThresholdConfig {
cpu: {
warning: number;
danger: number;
};
memory: {
warning: number;
danger: number;
};
threadBlocked: {
warning: number;
danger: number;
};
dbConnections: {
warning: number;
danger: number;
};
}
/**
*
*/
export interface MetricHistoryPoint {
timestamp: number;
heapUsed: number;
heapMax: number;
cpuUsage: number;
}

View File

@ -83,6 +83,19 @@ export default defineConfig(({ mode }) => {
console.log('🔌 WebSocket代理:', req.url, '→', proxyTarget);
});
}
},
'/actuator': {
target: proxyTarget,
changeOrigin: true,
// 打印代理信息
configure: (proxy, _options) => {
proxy.on('error', (err, _req, _res) => {
console.log('Actuator 代理错误', err);
});
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('📊 Actuator 代理请求:', req.method, req.url, '→', proxyTarget);
});
}
}
},
fs: {