refactor: 标准化项目结构 frontend/ + backend/ + SQLite + run.sh

- 目录重构: app/ -> frontend/,与 backend/ 并列
- 数据库: 默认 SQLite(零配置),修复 DSN() bug
- run.sh: 一键启动前后端,支持 start/stop/restart/status
- Makefile: install/dev/build/lint/clean 快捷命令
- vite.config.ts: 配置 dev server 端口 5173 + API 代理到 8080
- config.yaml: SQLite 默认配置,开箱即用
- docs/: 完整开发文档(项目结构/API/数据库/规范/部署)
- 修复: GORM foreign key 关联声明
- 修复: go get gorm.io/driver/sqlite 依赖
This commit is contained in:
attacker committed 2026-06-13 23:34:23 +08:00
1 parent 7d6674bddd
commit 97c4207ff8
132 files changed
+1572 -34

No files matched your search

@@ -0,0 +1,469 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Bell,
Plus,
Search,
CheckCircle,
AlertTriangle,
XCircle,
Clock,
Settings,
Mail,
MessageSquare,
Phone,
VolumeX,
} from 'lucide-react';
import { alertRules, alertEvents } from '@/mocks/data';
import type { AlertEvent } from '@/types';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
export function AlertManager() {
const [activeTab, setActiveTab] = useState('events');
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const filteredEvents = alertEvents.filter((event) => {
const matchesSearch =
event.ruleName.toLowerCase().includes(searchQuery.toLowerCase()) ||
event.target.toLowerCase().includes(searchQuery.toLowerCase());
const matchesSeverity = severityFilter === 'all' || event.severity === severityFilter;
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
return matchesSearch && matchesSeverity && matchesStatus;
});
const getSeverityBadge = (severity: AlertEvent['severity']) => {
switch (severity) {
case 'critical':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
严重
</Badge>
);
case 'warning':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
警告
</Badge>
);
case 'info':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
<Bell className="h-3 w-3 mr-1" />
提示
</Badge>
);
default:
return null;
}
};
const getStatusBadge = (status: AlertEvent['status']) => {
switch (status) {
case 'firing':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10 animate-pulse">
触发中
</Badge>
);
case 'resolved':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
已恢复
</Badge>
);
default:
return null;
}
};
// 告警趋势数据
const alertTrendData = [
{ time: '00:00', critical: 2, warning: 5, info: 8 },
{ time: '04:00', critical: 1, warning: 3, info: 6 },
{ time: '08:00', critical: 3, warning: 7, info: 12 },
{ time: '12:00', critical: 5, warning: 10, info: 15 },
{ time: '16:00', critical: 4, warning: 8, info: 10 },
{ time: '20:00', critical: 3, warning: 6, info: 9 },
{ time: '24:00', critical: 2, warning: 4, info: 7 },
];
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">监控告警</h1>
<p className="text-sm text-slate-500 mt-1">告警规则管理与告警事件处理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Settings className="h-4 w-4 mr-2" />
通知配置
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建规则
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">告警规则</p>
<p className="text-2xl font-bold text-slate-100">{alertRules.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Bell className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">触发中</p>
<p className="text-2xl font-bold text-red-400">
{alertEvents.filter((e) => e.status === 'firing').length}
</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<AlertTriangle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">今日告警</p>
<p className="text-2xl font-bold text-amber-400">{alertEvents.length}</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">已恢复</p>
<p className="text-2xl font-bold text-emerald-400">
{alertEvents.filter((e) => e.status === 'resolved').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Alert Trend Chart */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<CardTitle className="text-slate-100">告警趋势</CardTitle>
<CardDescription className="text-slate-500">过去24小时告警统计</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={alertTrendData}>
<defs>
<linearGradient id="colorCritical" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorWarning" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={12} />
<YAxis stroke="#475569" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
}}
/>
<Area
type="monotone"
dataKey="critical"
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorCritical)"
name="严重"
/>
<Area
type="monotone"
dataKey="warning"
stroke="#f59e0b"
fillOpacity={1}
fill="url(#colorWarning)"
name="警告"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
告警事件
</TabsTrigger>
<TabsTrigger value="rules" className="data-[state=active]:bg-slate-700">
告警规则
</TabsTrigger>
<TabsTrigger value="silences" className="data-[state=active]:bg-slate-700">
静默规则
</TabsTrigger>
</TabsList>
<TabsContent value="events" className="mt-4 space-y-4">
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索告警名称或目标..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="all">全部级别</option>
<option value="critical">严重</option>
<option value="warning">警告</option>
<option value="info">提示</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="all">全部状态</option>
<option value="firing">触发中</option>
<option value="resolved">已恢复</option>
</select>
</div>
{/* Events Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">级别</TableHead>
<TableHead className="text-slate-400">告警名称</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">目标</TableHead>
<TableHead className="text-slate-400">当前值</TableHead>
<TableHead className="text-slate-400">阈值</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.map((event) => (
<TableRow key={event.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>{getSeverityBadge(event.severity)}</TableCell>
<TableCell>
<span className="text-sm font-medium text-slate-200">{event.ruleName}</span>
</TableCell>
<TableCell>{getStatusBadge(event.status)}</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{event.target}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{event.value}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-500">{event.threshold}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{new Date(event.startedAt).toLocaleString()}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{event.status === 'firing' && (
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-emerald-400"
>
<CheckCircle className="h-4 w-4 mr-1" />
确认
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">规则名称</TableHead>
<TableHead className="text-slate-400">监控指标</TableHead>
<TableHead className="text-slate-400">触发条件</TableHead>
<TableHead className="text-slate-400">级别</TableHead>
<TableHead className="text-slate-400">持续时间</TableHead>
<TableHead className="text-slate-400">通知渠道</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alertRules.map((rule) => (
<TableRow key={rule.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div>
<span className="text-sm font-medium text-slate-200">{rule.name}</span>
<p className="text-xs text-slate-500">{rule.description}</p>
</div>
</TableCell>
<TableCell>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
{rule.metric}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">
{rule.operator} {rule.threshold}
</span>
</TableCell>
<TableCell>
{rule.severity === 'critical' ? (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
严重
</Badge>
) : rule.severity === 'warning' ? (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
警告
</Badge>
) : (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
提示
</Badge>
)}
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{rule.duration / 60}分钟</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
{rule.notifyChannels.includes('email') && (
<Mail className="h-4 w-4 text-slate-400" />
)}
{rule.notifyChannels.includes('sms') && (
<Phone className="h-4 w-4 text-slate-400" />
)}
{rule.notifyChannels.includes('dingtalk') && (
<MessageSquare className="h-4 w-4 text-slate-400" />
)}
</div>
</TableCell>
<TableCell>
<Switch checked={rule.enabled} className="data-[state=checked]:bg-blue-600" />
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Settings className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="silences" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8">
<div className="text-center">
<VolumeX className="h-12 w-12 mx-auto text-slate-500 mb-4" />
<h3 className="text-lg font-medium text-slate-300 mb-2">暂无静默规则</h3>
<p className="text-sm text-slate-500 mb-4">创建静默规则以临时抑制告警通知</p>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
创建静默规则
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,631 @@
import React, { useState, useEffect } from 'react';
import {
Bell, AlertTriangle, AlertCircle, CheckCircle, XCircle, Clock, Filter,
Search, RefreshCw, Settings, Eye, Check, X, Volume2, VolumeX, Mail,
MessageSquare, Phone, User, Server, TrendingUp, TrendingDown, BarChart3,
Calendar, Download, MoreHorizontal, ChevronDown, ChevronUp, Zap, Shield,
Activity, Layers, Info, Plus, ChevronRight
} 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, CardDescription } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { alertCenterApi } from '@/services/api';
interface Alert {
id: string;
title: string;
description: string;
severity: 'critical' | 'warning' | 'info';
status: 'firing' | 'resolved' | 'acknowledged' | 'suppressed';
source: string;
service: string;
metric: string;
value: number;
threshold: number;
startedAt: string;
resolvedAt?: string;
acknowledgedBy?: string;
acknowledgedAt?: string;
runbookUrl?: string;
labels: Record<string, string>;
annotations: Record<string, string>;
}
interface AlertRule {
id: string;
name: string;
description: string;
enabled: boolean;
severity: 'critical' | 'warning' | 'info';
query: string;
duration: string;
threshold: number;
labels: string[];
notificationChannels: string[];
lastEvaluated: string;
}
interface NotificationChannel {
id: string;
name: string;
type: 'email' | 'slack' | 'webhook' | 'sms' | 'pagerduty';
enabled: boolean;
config: Record<string, string>;
}
const AlertCenter: React.FC = () => {
const [alerts, setAlerts] = useState<Alert[]>([]);
const [rules, setRules] = useState<AlertRule[]>([]);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [activeTab, setActiveTab] = useState('alerts');
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [silenceOpen, setSilenceOpen] = useState(false);
const [stats, setStats] = useState({
firing: 0,
critical: 0,
warning: 0,
resolved: 0
});
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);
const fetchData = async () => {
await Promise.all([fetchAlerts(), fetchRules(), fetchChannels()]);
};
const fetchAlerts = async () => {
setLoading(true);
try {
const res = await alertCenterApi.getAlerts();
const data = res.data || [];
setAlerts(data);
setStats({
firing: data.filter((a: Alert) => a.status === 'firing').length,
critical: data.filter((a: Alert) => a.severity === 'critical' && a.status === 'firing').length,
warning: data.filter((a: Alert) => a.severity === 'warning' && a.status === 'firing').length,
resolved: data.filter((a: Alert) => a.status === 'resolved').length
});
} catch (error) {
toast.error('获取告警失败');
} finally {
setLoading(false);
}
};
const fetchRules = async () => {
try {
const res = await alertCenterApi.getRules();
setRules(res.data || []);
} catch (error) {
toast.error('获取告警规则失败');
}
};
const fetchChannels = async () => {
try {
const res = await alertCenterApi.getChannels();
setChannels(res.data || []);
} catch (error) {
toast.error('获取通知渠道失败');
}
};
const handleAcknowledge = async (id: string) => {
try {
await alertCenterApi.acknowledge(id);
toast.success('告警已确认');
fetchAlerts();
} catch (error) {
toast.error('操作失败');
}
};
const handleResolve = async (id: string) => {
try {
await alertCenterApi.resolve(id);
toast.success('告警已解决');
fetchAlerts();
} catch (error) {
toast.error('操作失败');
}
};
const handleSilence = async (id: string, duration: string) => {
try {
await alertCenterApi.silence(id, duration);
toast.success('告警已静默');
setSilenceOpen(false);
fetchAlerts();
} catch (error) {
toast.error('操作失败');
}
};
const toggleRule = async (ruleId: string, enabled: boolean) => {
try {
await alertCenterApi.toggleRule(ruleId, enabled);
setRules(rules.map(r => r.id === ruleId ? { ...r, enabled } : r));
toast.success('规则状态已更新');
} catch (error) {
toast.error('更新失败');
}
};
const getSeverityBadge = (severity: string) => {
switch (severity) {
case 'critical':
return <Badge variant="destructive" className="gap-1"><AlertCircle className="w-3 h-3" />严重</Badge>;
case 'warning':
return <Badge variant="default" className="bg-orange-500 gap-1"><AlertTriangle className="w-3 h-3" />警告</Badge>;
case 'info':
return <Badge variant="default" className="bg-blue-500 gap-1"><Info className="w-3 h-3" />信息</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'firing':
return <Badge variant="destructive" className="gap-1 animate-pulse"><Zap className="w-3 h-3" />触发中</Badge>;
case 'acknowledged':
return <Badge variant="default" className="bg-yellow-500 gap-1"><User className="w-3 h-3" />已确认</Badge>;
case 'resolved':
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />已解决</Badge>;
case 'suppressed':
return <Badge variant="secondary" className="gap-1"><VolumeX className="w-3 h-3" />已静默</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const getChannelIcon = (type: string) => {
switch (type) {
case 'email': return <Mail className="w-4 h-4" />;
case 'slack': return <MessageSquare className="w-4 h-4" />;
case 'sms': return <Phone className="w-4 h-4" />;
case 'webhook': return <Zap className="w-4 h-4" />;
default: return <Bell className="w-4 h-4" />;
}
};
const filteredAlerts = alerts.filter(alert => {
const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
alert.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
alert.source.toLowerCase().includes(searchQuery.toLowerCase());
const matchSeverity = severityFilter === 'all' || alert.severity === severityFilter;
const matchStatus = statusFilter === 'all' || alert.status === statusFilter;
return matchSearch && matchSeverity && matchStatus;
});
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">触发中</p>
<p className={`text-2xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
</div>
<div className={`p-3 rounded-lg ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
<Zap className={`w-5 h-5 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">严重告警</p>
<p className="text-2xl font-bold text-red-600">{stats.critical}</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">警告告警</p>
<p className="text-2xl font-bold text-orange-600">{stats.warning}</p>
</div>
<div className="p-3 bg-orange-100 rounded-lg">
<AlertTriangle className="w-5 h-5 text-orange-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">今日已解决</p>
<p className="text-2xl font-bold text-green-600">{stats.resolved}</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="alerts">告警列表</TabsTrigger>
<TabsTrigger value="rules">告警规则</TabsTrigger>
<TabsTrigger value="channels">通知渠道</TabsTrigger>
</TabsList>
<TabsContent value="alerts" className="space-y-4">
{/* 过滤栏 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索告警标题、描述、来源..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={severityFilter} onValueChange={setSeverityFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="级别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="critical">严重</SelectItem>
<SelectItem value="warning">警告</SelectItem>
<SelectItem value="info">信息</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="firing">触发中</SelectItem>
<SelectItem value="acknowledged">已确认</SelectItem>
<SelectItem value="resolved">已解决</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchAlerts}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
<Button variant="outline" onClick={() => toast.info('批量操作功能开发中')}>
<Check className="w-4 h-4 mr-2" />
批量确认
</Button>
</div>
</div>
{/* 告警列表 */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">告警信息</th>
<th className="px-4 py-3 text-left text-sm font-medium">级别</th>
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
<th className="px-4 py-3 text-left text-sm font-medium">来源</th>
<th className="px-4 py-3 text-left text-sm font-medium">持续时间</th>
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">加载中...</td>
</tr>
) : filteredAlerts.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无告警</td>
</tr>
) : (
filteredAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<div className="font-medium">{alert.title}</div>
<div className="text-sm text-muted-foreground line-clamp-1">{alert.description}</div>
<div className="flex gap-1 mt-1">
{Object.entries(alert.labels).slice(0, 3).map(([k, v]) => (
<Badge key={k} variant="outline" className="text-xs">{k}={v}</Badge>
))}
</div>
</td>
<td className="px-4 py-3">{getSeverityBadge(alert.severity)}</td>
<td className="px-4 py-3">{getStatusBadge(alert.status)}</td>
<td className="px-4 py-3 text-sm">
<div>{alert.source}</div>
<div className="text-muted-foreground">{alert.service}</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{alert.status === 'resolved' && alert.resolvedAt
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}
>
<Eye className="w-4 h-4" />
</Button>
{alert.status === 'firing' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleAcknowledge(alert.id)}
>
<Check className="w-4 h-4 text-blue-600" />
</Button>
)}
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
<Button
variant="ghost"
size="sm"
onClick={() => handleResolve(alert.id)}
>
<CheckCircle className="w-4 h-4 text-green-600" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}
>
<VolumeX className="w-4 h-4 text-orange-600" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules" className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchRules}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
</div>
<Button onClick={() => toast.info('创建规则功能开发中')}>
<Plus className="w-4 h-4 mr-2" />
新建规则
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{rules.map((rule) => (
<Card key={rule.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{rule.name}</span>
{getSeverityBadge(rule.severity)}
<Badge variant={rule.enabled ? 'default' : 'secondary'}>
{rule.enabled ? '已启用' : '已禁用'}
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-2">{rule.description}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono bg-muted px-2 py-1 rounded">{rule.query}</span>
<span>持续 {rule.duration}</span>
<span>阈值: {rule.threshold}</span>
</div>
<div className="flex gap-1 mt-2">
{rule.notificationChannels.map(ch => (
<Badge key={ch} variant="outline" className="text-xs">{ch}</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => toggleRule(rule.id, checked)}
/>
<Button variant="ghost" size="sm">
<Settings className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="channels" className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchChannels}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
</div>
<Button onClick={() => toast.info('添加渠道功能开发中')}>
<Plus className="w-4 h-4 mr-2" />
添加渠道
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((channel) => (
<Card key={channel.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{getChannelIcon(channel.type)}
<CardTitle className="text-base">{channel.name}</CardTitle>
</div>
<Badge variant={channel.enabled ? 'default' : 'secondary'}>
{channel.enabled ? '已启用' : '已禁用'}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-sm text-muted-foreground capitalize mb-4">
类型: {channel.type}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1">
<Settings className="w-4 h-4 mr-2" />
配置
</Button>
<Button variant="outline" size="sm" className="flex-1">
<Zap className="w-4 h-4 mr-2" />
测试
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
{/* 告警详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedAlert?.title}
{selectedAlert && getSeverityBadge(selectedAlert.severity)}
</DialogTitle>
</DialogHeader>
{selectedAlert && (
<div className="space-y-4">
<div>
<Label className="text-muted-foreground">描述</Label>
<p className="mt-1">{selectedAlert.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">来源</Label>
<p className="font-medium">{selectedAlert.source}</p>
</div>
<div>
<Label className="text-muted-foreground">服务</Label>
<p className="font-medium">{selectedAlert.service}</p>
</div>
<div>
<Label className="text-muted-foreground">指标</Label>
<p className="font-medium">{selectedAlert.metric}</p>
</div>
<div>
<Label className="text-muted-foreground">当前值 / 阈值</Label>
<p className="font-medium">{selectedAlert.value} / {selectedAlert.threshold}</p>
</div>
<div>
<Label className="text-muted-foreground">开始时间</Label>
<p className="font-medium">{selectedAlert.startedAt}</p>
</div>
{selectedAlert.resolvedAt && (
<div>
<Label className="text-muted-foreground">解决时间</Label>
<p className="font-medium">{selectedAlert.resolvedAt}</p>
</div>
)}
</div>
<div>
<Label className="text-muted-foreground">标签</Label>
<div className="flex flex-wrap gap-2 mt-1">
{Object.entries(selectedAlert.labels).map(([k, v]) => (
<Badge key={k} variant="outline">{k}={v}</Badge>
))}
</div>
</div>
{selectedAlert.runbookUrl && (
<div>
<Label className="text-muted-foreground">处理手册</Label>
<a href={selectedAlert.runbookUrl} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline block mt-1">
查看处理手册 →
</a>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}>关闭</Button>
{selectedAlert?.status === 'firing' && (
<Button onClick={() => { handleAcknowledge(selectedAlert.id); setDetailOpen(false); }}>
<Check className="w-4 h-4 mr-2" />
确认告警
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 静默对话框 */}
<Dialog open={silenceOpen} onOpenChange={setSilenceOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>静默告警</DialogTitle>
<DialogDescription>选择静默持续时间</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{['1h', '4h', '8h', '24h', '7d'].map((duration) => (
<Button
key={duration}
variant="outline"
className="w-full justify-between"
onClick={() => selectedAlert && handleSilence(selectedAlert.id, duration)}
>
<span>静默 {duration}</span>
<ChevronRight className="w-4 h-4" />
</Button>
))}
</div>
</DialogContent>
</Dialog>
</div>
);
};
export default AlertCenter;
@@ -0,0 +1,715 @@
import React, { useState, useEffect } from 'react';
import {
Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw,
Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart,
Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp,
TrendingDown, Clock, Calendar, Filter, Search, GripVertical, Move
} 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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import { dashboardApi } from '@/services/api';
interface Dashboard {
id: string;
name: string;
description: string;
datasource: 'prometheus' | 'zabbix' | 'skywalking' | 'custom';
datasourceUrl: string;
panels: Panel[];
createdAt: string;
updatedAt: string;
createdBy: string;
isDefault: boolean;
}
interface Panel {
id: string;
title: string;
type: 'graph' | 'stat' | 'gauge' | 'table' | 'pie' | 'heatmap' | 'text';
query: string;
datasource: string;
width: number;
height: number;
x: number;
y: number;
options: PanelOptions;
}
interface PanelOptions {
unit?: string;
min?: number;
max?: number;
thresholds?: Threshold[];
legend?: boolean;
tooltip?: boolean;
refreshInterval?: number;
}
interface Threshold {
value: number;
color: string;
name: string;
}
interface Datasource {
id: string;
name: string;
type: 'prometheus' | 'zabbix' | 'skywalking' | 'influxdb' | 'elasticsearch';
url: string;
status: 'connected' | 'disconnected' | 'error';
}
const GrafanaDashboard: React.FC = () => {
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [datasources, setDatasources] = useState<Datasource[]>([]);
const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(null);
const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [addPanelOpen, setAddPanelOpen] = useState(false);
const [datasourceOpen, setDatasourceOpen] = useState(false);
const [newDashboard, setNewDashboard] = useState({
name: '',
description: '',
datasource: 'prometheus' as const,
datasourceUrl: ''
});
const [newPanel, setNewPanel] = useState({
title: '',
type: 'graph' as const,
query: '',
width: 6,
height: 4
});
const [newDatasource, setNewDatasource] = useState({
name: '',
type: 'prometheus' as const,
url: ''
});
useEffect(() => {
fetchDashboards();
fetchDatasources();
}, []);
const fetchDashboards = async () => {
setLoading(true);
try {
const res = await dashboardApi.getDashboards();
setDashboards(res.data || []);
if (res.data?.length > 0 && !activeDashboard) {
setActiveDashboard(res.data[0]);
}
} catch (error) {
toast.error('获取仪表盘失败');
} finally {
setLoading(false);
}
};
const fetchDatasources = async () => {
try {
const res = await dashboardApi.getDatasources();
setDatasources(res.data || []);
} catch (error) {
toast.error('获取数据源失败');
}
};
const handleCreateDashboard = async () => {
if (!newDashboard.name) {
toast.error('请输入仪表盘名称');
return;
}
try {
await dashboardApi.createDashboard({
...newDashboard,
panels: []
});
toast.success('仪表盘创建成功');
setCreateOpen(false);
setNewDashboard({ name: '', description: '', datasource: 'prometheus', datasourceUrl: '' });
fetchDashboards();
} catch (error) {
toast.error('创建失败');
}
};
const handleAddPanel = async () => {
if (!newPanel.title || !newPanel.query || !activeDashboard) {
toast.error('请填写完整信息');
return;
}
try {
const panel: Panel = {
id: Date.now().toString(),
...newPanel,
datasource: activeDashboard.datasource,
x: 0,
y: 0,
options: {
unit: '',
legend: true,
tooltip: true,
refreshInterval: 30
}
};
await dashboardApi.updateDashboard(activeDashboard.id, {
...activeDashboard,
panels: [...activeDashboard.panels, panel]
});
toast.success('面板添加成功');
setAddPanelOpen(false);
setNewPanel({ title: '', type: 'graph', query: '', width: 6, height: 4 });
fetchDashboards();
} catch (error) {
toast.error('添加失败');
}
};
const handleAddDatasource = async () => {
if (!newDatasource.name || !newDatasource.url) {
toast.error('请填写完整信息');
return;
}
try {
await dashboardApi.createDatasource(newDatasource);
toast.success('数据源添加成功');
setDatasourceOpen(false);
setNewDatasource({ name: '', type: 'prometheus', url: '' });
fetchDatasources();
} catch (error) {
toast.error('添加失败');
}
};
const handleDeleteDashboard = async (id: string) => {
if (!confirm('确定删除此仪表盘吗?')) return;
try {
await dashboardApi.deleteDashboard(id);
toast.success('删除成功');
if (activeDashboard?.id === id) {
setActiveDashboard(null);
}
fetchDashboards();
} catch (error) {
toast.error('删除失败');
}
};
const getDatasourceIcon = (type: string) => {
switch (type) {
case 'prometheus': return <Activity className="w-4 h-4" />;
case 'zabbix': return <Server className="w-4 h-4" />;
case 'skywalking': return <Globe className="w-4 h-4" />;
case 'influxdb': return <Database className="w-4 h-4" />;
default: return <Database className="w-4 h-4" />;
}
};
const getPanelIcon = (type: string) => {
switch (type) {
case 'graph': return <LineChart className="w-4 h-4" />;
case 'stat': return <Activity className="w-4 h-4" />;
case 'gauge': return <Cpu className="w-4 h-4" />;
case 'pie': return <PieChart className="w-4 h-4" />;
case 'table': return <BarChart3 className="w-4 h-4" />;
default: return <BarChart3 className="w-4 h-4" />;
}
};
const getDatasourceStatusBadge = (status: string) => {
switch (status) {
case 'connected':
return <Badge variant="default" className="bg-green-500">已连接</Badge>;
case 'disconnected':
return <Badge variant="secondary">已断开</Badge>;
case 'error':
return <Badge variant="destructive">错误</Badge>;
default:
return <Badge variant="outline">未知</Badge>;
}
};
// 模拟图表数据
const generateChartData = (panel: Panel) => {
const dataPoints = 24;
const data = [];
for (let i = 0; i < dataPoints; i++) {
data.push({
time: new Date(Date.now() - (dataPoints - i) * 3600000).toLocaleTimeString(),
value: Math.random() * 100
});
}
return data;
};
// 渲染面板内容
const renderPanelContent = (panel: Panel) => {
const data = generateChartData(panel);
switch (panel.type) {
case 'stat':
return (
<div className="flex flex-col items-center justify-center h-full">
<span className="text-4xl font-bold text-blue-500">{Math.floor(Math.random() * 1000)}</span>
<span className="text-sm text-muted-foreground mt-2">{panel.options?.unit || 'requests/s'}</span>
<div className="flex items-center gap-1 mt-2 text-green-500 text-sm">
<TrendingUp className="w-4 h-4" />
<span>+12.5%</span>
</div>
</div>
);
case 'gauge':
return (
<div className="flex flex-col items-center justify-center h-full">
<div className="relative w-24 h-24">
<svg className="w-full h-full" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="#e5e7eb" strokeWidth="10" />
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="#3b82f6"
strokeWidth="10"
strokeDasharray={`${Math.random() * 251} 251`}
transform="rotate(-90 50 50)"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold">{Math.floor(Math.random() * 100)}%</span>
</div>
</div>
<span className="text-sm text-muted-foreground mt-2">CPU使用率</span>
</div>
);
case 'graph':
default:
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex items-end gap-1 px-2">
{data.map((d, i) => (
<div
key={i}
className="flex-1 bg-blue-500 rounded-t"
style={{ height: `${d.value}%`, opacity: 0.3 + (i / data.length) * 0.7 }}
/>
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground px-2 pt-2">
<span>{data[0].time}</span>
<span>{data[data.length - 1].time}</span>
</div>
</div>
);
}
};
return (
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
{/* 顶部工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Select
value={activeDashboard?.id}
onValueChange={(id) => {
const dashboard = dashboards.find(d => d.id === id);
if (dashboard) setActiveDashboard(dashboard);
}}
>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder="选择仪表盘" />
</SelectTrigger>
<SelectContent>
{dashboards.map(d => (
<SelectItem key={d.id} value={d.id}>
<div className="flex items-center gap-2">
{getDatasourceIcon(d.datasource)}
{d.name}
{d.isDefault && <Badge variant="outline" className="text-xs">默认</Badge>}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{activeDashboard && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>更新于 {activeDashboard.updatedAt}</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchDashboards}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
{activeDashboard && (
<>
<Button
variant={editMode ? 'default' : 'outline'}
size="sm"
onClick={() => setEditMode(!editMode)}
>
<Edit className="w-4 h-4 mr-2" />
{editMode ? '完成' : '编辑'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddPanelOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
添加面板
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setFullscreen(!fullscreen)}>
{fullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
<Button variant="outline" size="sm" onClick={() => setDatasourceOpen(true)}>
<Database className="w-4 h-4 mr-2" />
数据源
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
新建仪表盘
</Button>
</div>
</div>
{/* 仪表盘内容 */}
{activeDashboard ? (
<div className="space-y-4">
{/* 仪表盘信息 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{activeDashboard.name}</h2>
<p className="text-sm text-muted-foreground">{activeDashboard.description}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1">
{getDatasourceIcon(activeDashboard.datasource)}
{activeDashboard.datasource}
</Badge>
</div>
</div>
{/* 面板网格 */}
<div className="grid grid-cols-12 gap-4">
{activeDashboard.panels.map((panel) => (
<Card
key={panel.id}
className={`col-span-${panel.width} ${editMode ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
style={{ gridColumn: `span ${panel.width}`, minHeight: `${panel.height * 80}px` }}
>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{getPanelIcon(panel.type)}
{panel.title}
</CardTitle>
{editMode && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<GripVertical className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-500">
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
{renderPanelContent(panel)}
</CardContent>
</Card>
))}
{activeDashboard.panels.length === 0 && (
<div className="col-span-12 py-16 text-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>暂无面板,点击"添加面板"开始创建</p>
</div>
)}
</div>
</div>
) : (
<div className="py-16 text-center text-muted-foreground">
<Monitor className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>请选择一个仪表盘或创建新的仪表盘</p>
<Button className="mt-4" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
新建仪表盘
</Button>
</div>
)}
{/* 新建仪表盘对话框 */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>新建仪表盘</DialogTitle>
<DialogDescription>创建一个新的监控仪表盘</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>名称</Label>
<Input
value={newDashboard.name}
onChange={(e) => setNewDashboard({ ...newDashboard, name: e.target.value })}
placeholder="仪表盘名称"
/>
</div>
<div>
<Label>描述</Label>
<Input
value={newDashboard.description}
onChange={(e) => setNewDashboard({ ...newDashboard, description: e.target.value })}
placeholder="仪表盘描述"
/>
</div>
<div>
<Label>数据源类型</Label>
<Select
value={newDashboard.datasource}
onValueChange={(v) => setNewDashboard({ ...newDashboard, datasource: v as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="prometheus">Prometheus</SelectItem>
<SelectItem value="zabbix">Zabbix</SelectItem>
<SelectItem value="skywalking">SkyWalking</SelectItem>
<SelectItem value="custom">自定义</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>数据源地址</Label>
<Input
value={newDashboard.datasourceUrl}
onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })}
placeholder="http://localhost:9090"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
<Button onClick={handleCreateDashboard}>
<Save className="w-4 h-4 mr-2" />
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 添加面板对话框 */}
<Dialog open={addPanelOpen} onOpenChange={setAddPanelOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>添加面板</DialogTitle>
<DialogDescription>添加一个新的监控面板</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>标题</Label>
<Input
value={newPanel.title}
onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })}
placeholder="面板标题"
/>
</div>
<div>
<Label>类型</Label>
<Select
value={newPanel.type}
onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="graph">折线图</SelectItem>
<SelectItem value="stat">统计值</SelectItem>
<SelectItem value="gauge">仪表盘</SelectItem>
<SelectItem value="pie">饼图</SelectItem>
<SelectItem value="table">表格</SelectItem>
<SelectItem value="heatmap">热力图</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>查询语句</Label>
<textarea
value={newPanel.query}
onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })}
placeholder={newPanel.type === 'graph' ? 'sum(rate(http_requests_total[5m]))' : 'up'}
className="w-full min-h-[100px] p-2 border rounded-md font-mono text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>宽度 (列)</Label>
<Select
value={newPanel.width.toString()}
onValueChange={(v) => setNewPanel({ ...newPanel, width: parseInt(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3列 (25%)</SelectItem>
<SelectItem value="4">4列 (33%)</SelectItem>
<SelectItem value="6">6列 (50%)</SelectItem>
<SelectItem value="8">8列 (66%)</SelectItem>
<SelectItem value="12">12列 (100%)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>高度 (行)</Label>
<Select
value={newPanel.height.toString()}
onValueChange={(v) => setNewPanel({ ...newPanel, height: parseInt(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3行 (短)</SelectItem>
<SelectItem value="4">4行 (中)</SelectItem>
<SelectItem value="6">6行 (长)</SelectItem>
<SelectItem value="8">8行 (超长)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button>
<Button onClick={handleAddPanel}>
<Plus className="w-4 h-4 mr-2" />
添加
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 数据源管理对话框 */}
<Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>数据源管理</DialogTitle>
<DialogDescription>管理监控数据源配置</DialogDescription>
</DialogHeader>
<Tabs defaultValue="list">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="list">数据源列表</TabsTrigger>
<TabsTrigger value="add">添加数据源</TabsTrigger>
</TabsList>
<TabsContent value="list" className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{datasources.map((ds) => (
<Card key={ds.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getDatasourceIcon(ds.type)}
<div>
<p className="font-medium">{ds.name}</p>
<p className="text-sm text-muted-foreground">{ds.url}</p>
</div>
</div>
<div className="flex items-center gap-2">
{getDatasourceStatusBadge(ds.status)}
<Button variant="ghost" size="sm">
<Settings className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="text-red-500">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="add" className="space-y-4">
<div>
<Label>名称</Label>
<Input
value={newDatasource.name}
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
placeholder="数据源名称"
/>
</div>
<div>
<Label>类型</Label>
<Select
value={newDatasource.type}
onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="prometheus">Prometheus</SelectItem>
<SelectItem value="zabbix">Zabbix</SelectItem>
<SelectItem value="skywalking">SkyWalking</SelectItem>
<SelectItem value="influxdb">InfluxDB</SelectItem>
<SelectItem value="elasticsearch">Elasticsearch</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>地址</Label>
<Input
value={newDatasource.url}
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
placeholder="http://localhost:9090"
/>
</div>
<Button onClick={handleAddDatasource} className="w-full">
<Plus className="w-4 h-4 mr-2" />
添加数据源
</Button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</div>
);
};
export default GrafanaDashboard;
@@ -0,0 +1,618 @@
import React, { useState, useEffect } from 'react';
import {
Search, Filter, Clock, Server, ArrowRight, ChevronRight, ChevronDown, Activity,
Zap, AlertCircle, CheckCircle, XCircle, BarChart3, PieChart, TrendingUp,
Download, RefreshCw, Eye, Maximize2, Minimize2, Target, Layers, GitBranch,
Cpu, Database, Globe, Code, Box, Container, Settings, Info
} 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, CardDescription } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { traceApi } from '@/services/api';
interface Trace {
id: string;
traceId: string;
serviceName: string;
operationName: string;
startTime: string;
duration: number;
status: 'success' | 'error' | 'pending';
spanCount: number;
tags: Record<string, string>;
spans?: Span[];
}
interface Span {
id: string;
spanId: string;
parentSpanId?: string;
serviceName: string;
operationName: string;
startTime: string;
duration: number;
status: 'success' | 'error' | 'pending';
kind: 'client' | 'server' | 'producer' | 'consumer' | 'internal';
tags: Record<string, string>;
logs: SpanLog[];
children: Span[];
}
interface SpanLog {
timestamp: string;
fields: Record<string, string>;
}
interface ServiceMetrics {
serviceName: string;
requestCount: number;
errorRate: number;
avgDuration: number;
p95Duration: number;
p99Duration: number;
}
const TraceManager: React.FC = () => {
const [traces, setTraces] = useState<Trace[]>([]);
const [services, setServices] = useState<ServiceMetrics[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [serviceFilter, setServiceFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('1h');
const [activeTab, setActiveTab] = useState('traces');
const [selectedTrace, setSelectedTrace] = useState<Trace | null>(null);
const [selectedSpan, setSelectedSpan] = useState<Span | null>(null);
const [traceDetailOpen, setTraceDetailOpen] = useState(false);
const [spanDetailOpen, setSpanDetailOpen] = useState(false);
useEffect(() => {
fetchData();
}, [timeRange]);
const fetchData = async () => {
await Promise.all([fetchTraces(), fetchServices()]);
};
const fetchTraces = async () => {
setLoading(true);
try {
const res = await traceApi.getTraces({ timeRange, service: serviceFilter, status: statusFilter });
setTraces(res.data || []);
} catch (error) {
toast.error('获取链路数据失败');
} finally {
setLoading(false);
}
};
const fetchServices = async () => {
try {
const res = await traceApi.getServices(timeRange);
setServices(res.data || []);
} catch (error) {
toast.error('获取服务数据失败');
}
};
const fetchTraceDetail = async (traceId: string) => {
try {
const res = await traceApi.getTraceDetail(traceId);
return res.data;
} catch (error) {
toast.error('获取链路详情失败');
return null;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'success':
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />成功</Badge>;
case 'error':
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />错误</Badge>;
case 'pending':
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />进行中</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const getKindIcon = (kind: string) => {
switch (kind) {
case 'client': return <ArrowRight className="w-4 h-4" />;
case 'server': return <Server className="w-4 h-4" />;
case 'producer': return <Zap className="w-4 h-4" />;
case 'consumer': return <Box className="w-4 h-4" />;
default: return <Code className="w-4 h-4" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'success': return 'bg-green-500';
case 'error': return 'bg-red-500';
case 'pending': return 'bg-yellow-500';
default: return 'bg-gray-500';
}
};
const formatDuration = (ms: number) => {
if (ms < 1) return `${(ms * 1000).toFixed(2)}μs`;
if (ms < 1000) return `${ms.toFixed(2)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
const filteredTraces = traces.filter(trace => {
const matchSearch = trace.traceId.toLowerCase().includes(searchQuery.toLowerCase()) ||
trace.operationName.toLowerCase().includes(searchQuery.toLowerCase());
const matchService = serviceFilter === 'all' || trace.serviceName === serviceFilter;
const matchStatus = statusFilter === 'all' || trace.status === statusFilter;
return matchSearch && matchService && matchStatus;
});
const serviceNames = Array.from(new Set(traces.map(t => t.serviceName)));
// 构建Span树
const buildSpanTree = (spans: Span[]): Span[] => {
const spanMap = new Map<string, Span>();
const roots: Span[] = [];
spans.forEach(span => {
spanMap.set(span.spanId, { ...span, children: [] });
});
spans.forEach(span => {
const node = spanMap.get(span.spanId)!;
if (span.parentSpanId && spanMap.has(span.parentSpanId)) {
const parent = spanMap.get(span.parentSpanId)!;
parent.children = parent.children || [];
parent.children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
// 渲染Span树
const SpanTreeNode: React.FC<{ span: Span; level: number; totalDuration: number }> = ({ span, level, totalDuration }) => {
const [expanded, setExpanded] = useState(true);
const widthPercent = totalDuration > 0 ? (span.duration / totalDuration) * 100 : 0;
const offsetPercent = 0; // Simplified
return (
<div className="select-none">
<div
className="flex items-center py-2 hover:bg-muted/50 cursor-pointer border-l-2 border-transparent hover:border-blue-500"
style={{ paddingLeft: `${level * 24 + 12}px` }}
onClick={() => { setSelectedSpan(span); setSpanDetailOpen(true); }}
>
{span.children?.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 mr-1"
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
>
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</Button>
)}
{!span.children?.length && <span className="w-5" />}
<div className="flex items-center gap-2 flex-1 min-w-0">
{getKindIcon(span.kind)}
<span className="font-medium truncate">{span.operationName}</span>
<span className="text-xs text-muted-foreground">{span.serviceName}</span>
</div>
<div className="flex items-center gap-4">
<div className="w-32 h-4 bg-muted rounded overflow-hidden">
<div
className={`h-full ${getStatusColor(span.status)}`}
style={{ width: `${Math.max(widthPercent, 1)}%` }}
/>
</div>
<span className="text-sm text-muted-foreground w-20 text-right">
{formatDuration(span.duration)}
</span>
{span.status === 'error' && <AlertCircle className="w-4 h-4 text-red-500" />}
</div>
</div>
{expanded && span.children?.map(child => (
<SpanTreeNode key={child.spanId} span={child} level={level + 1} totalDuration={totalDuration} />
))}
</div>
);
};
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">总请求数</p>
<p className="text-2xl font-bold">{services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString()}</p>
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<Activity className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">平均错误率</p>
<p className="text-2xl font-bold">
{(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%
</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">平均延迟</p>
<p className="text-2xl font-bold">
{formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1))}
</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<Clock className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">服务数量</p>
<p className="text-2xl font-bold">{services.length}</p>
</div>
<div className="p-3 bg-purple-100 rounded-lg">
<Layers className="w-5 h-5 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="traces">链路追踪</TabsTrigger>
<TabsTrigger value="services">服务拓扑</TabsTrigger>
<TabsTrigger value="metrics">性能指标</TabsTrigger>
</TabsList>
<TabsContent value="traces" className="space-y-4">
{/* 查询工具栏 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 min-w-[400px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索Trace ID、操作名..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="服务" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部服务</SelectItem>
{serviceNames.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">错误</SelectItem>
<SelectItem value="pending">进行中</SelectItem>
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15m">15分钟</SelectItem>
<SelectItem value="1h">1小时</SelectItem>
<SelectItem value="6h">6小时</SelectItem>
<SelectItem value="24h">24小时</SelectItem>
<SelectItem value="7d">7天</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchTraces}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
</div>
</div>
{/* Trace列表 */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Trace ID</th>
<th className="px-4 py-3 text-left text-sm font-medium">服务 / 操作</th>
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
<th className="px-4 py-3 text-left text-sm font-medium">耗时</th>
<th className="px-4 py-3 text-left text-sm font-medium">Span数</th>
<th className="px-4 py-3 text-left text-sm font-medium">时间</th>
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">加载中...</td>
</tr>
) : filteredTraces.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">暂无链路数据</td>
</tr>
) : (
filteredTraces.map((trace) => (
<tr key={trace.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<span className="font-mono text-sm text-blue-500">{trace.traceId.slice(0, 16)}...</span>
</td>
<td className="px-4 py-3">
<div className="font-medium">{trace.operationName}</div>
<div className="text-sm text-muted-foreground">{trace.serviceName}</div>
</td>
<td className="px-4 py-3">{getStatusBadge(trace.status)}</td>
<td className="px-4 py-3 text-sm">{formatDuration(trace.duration)}</td>
<td className="px-4 py-3 text-sm">{trace.spanCount}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{trace.startTime}</td>
<td className="px-4 py-3">
<Button
variant="ghost"
size="sm"
onClick={async () => {
const detail = await fetchTraceDetail(trace.traceId);
if (detail) {
setSelectedTrace({ ...trace, spans: detail.spans });
setTraceDetailOpen(true);
}
}}
>
<Eye className="w-4 h-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="services" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>服务性能概览</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">服务名称</th>
<th className="px-4 py-3 text-left text-sm font-medium">请求数</th>
<th className="px-4 py-3 text-left text-sm font-medium">错误率</th>
<th className="px-4 py-3 text-left text-sm font-medium">平均延迟</th>
<th className="px-4 py-3 text-left text-sm font-medium">P95延迟</th>
<th className="px-4 py-3 text-left text-sm font-medium">P99延迟</th>
</tr>
</thead>
<tbody className="divide-y">
{services.map((service) => (
<tr key={service.serviceName} className="hover:bg-muted/50">
<td className="px-4 py-3 font-medium">{service.serviceName}</td>
<td className="px-4 py-3">{service.requestCount.toLocaleString()}</td>
<td className="px-4 py-3">
<span className={service.errorRate > 1 ? 'text-red-500' : 'text-green-500'}>
{service.errorRate.toFixed(2)}%
</span>
</td>
<td className="px-4 py-3">{formatDuration(service.avgDuration)}</td>
<td className="px-4 py-3">{formatDuration(service.p95Duration)}</td>
<td className="px-4 py-3">{formatDuration(service.p99Duration)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="metrics" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>延迟分布</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mr-4" />
延迟分布图表(集成图表库后显示)
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>错误率趋势</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<TrendingUp className="w-16 h-16 mr-4" />
错误率趋势图表(集成图表库后显示)
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
{/* Trace详情对话框 */}
<Dialog open={traceDetailOpen} onOpenChange={setTraceDetailOpen}>
<DialogContent className="max-w-6xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" />
Trace详情
<span className="font-mono text-sm text-muted-foreground">{selectedTrace?.traceId}</span>
</DialogTitle>
</DialogHeader>
{selectedTrace && (
<div className="space-y-4">
<div className="flex items-center gap-4 p-3 bg-muted rounded-lg">
<div>
<Label className="text-xs text-muted-foreground">服务</Label>
<p className="font-medium">{selectedTrace.serviceName}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">操作</Label>
<p className="font-medium">{selectedTrace.operationName}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">总耗时</Label>
<p className="font-medium">{formatDuration(selectedTrace.duration)}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">状态</Label>
<p>{getStatusBadge(selectedTrace.status)}</p>
</div>
</div>
<ScrollArea className="h-[500px]">
<div className="border rounded-lg">
<div className="flex items-center px-4 py-2 bg-muted/50 border-b text-sm font-medium">
<span className="flex-1">Span名称</span>
<span className="w-32">时间线</span>
<span className="w-20 text-right">耗时</span>
</div>
{/* Span树渲染 */}
{(selectedTrace as any).spans && buildSpanTree((selectedTrace as any).spans).map(span => (
<SpanTreeNode key={span.spanId} span={span} level={0} totalDuration={selectedTrace.duration} />
))}
</div>
</ScrollArea>
</div>
)}
</DialogContent>
</Dialog>
{/* Span详情对话框 */}
<Dialog open={spanDetailOpen} onOpenChange={setSpanDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Span详情</DialogTitle>
</DialogHeader>
{selectedSpan && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">Span ID</Label>
<p className="font-mono text-sm">{selectedSpan.spanId}</p>
</div>
<div>
<Label className="text-muted-foreground">类型</Label>
<p className="capitalize">{selectedSpan.kind}</p>
</div>
<div>
<Label className="text-muted-foreground">服务</Label>
<p className="font-medium">{selectedSpan.serviceName}</p>
</div>
<div>
<Label className="text-muted-foreground">操作</Label>
<p className="font-medium">{selectedSpan.operationName}</p>
</div>
<div>
<Label className="text-muted-foreground">状态</Label>
<p>{getStatusBadge(selectedSpan.status)}</p>
</div>
<div>
<Label className="text-muted-foreground">耗时</Label>
<p className="font-medium">{formatDuration(selectedSpan.duration)}</p>
</div>
</div>
{Object.keys(selectedSpan.tags).length > 0 && (
<div>
<Label className="text-muted-foreground">标签</Label>
<div className="mt-1 p-3 bg-muted rounded-lg">
{Object.entries(selectedSpan.tags).map(([k, v]) => (
<div key={k} className="flex justify-between text-sm py-1">
<span className="text-muted-foreground">{k}</span>
<span className="font-mono">{v}</span>
</div>
))}
</div>
</div>
)}
{selectedSpan.logs?.length > 0 && (
<div>
<Label className="text-muted-foreground">日志</Label>
<div className="mt-1 space-y-2">
{selectedSpan.logs.map((log, idx) => (
<div key={idx} className="p-2 bg-muted rounded text-sm">
<span className="text-muted-foreground">{log.timestamp}</span>
<pre className="mt-1 text-xs">{JSON.stringify(log.fields, null, 2)}</pre>
</div>
))}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
);
};
export default TraceManager;