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:
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;
|
||||
Reference in new issue
Block a user