refactor: AlertCenter拆分(723→390行)+修复TopologyView类型错误
- AlertCenter.tsx: 723→390行,提取类型/helpers/渠道Tab到子组件 - alert-center-helpers.tsx: 新建(127行),共享Alert/AlertRule/NotificationChannel类型+mapAlert+badge helpers - AlertChannelDialog.tsx: 新建(121行),通知渠道创建/编辑对话框(自包含useEffect同步) - AlertChannelsTab.tsx: 新建(152行),通知渠道Tab自包含(状态+API+渲染+CRUD) - AlertCenterDetail.tsx: 去重getSeverityBadge,统一从helpers导入 - TopologyView.tsx: 修复editForm.type类型推断(string→TopologyNode['type'])
This commit is contained in:
1 parent
805cb767a8
commit
76a41f5eeb
6 files changed
+458
-406
No files matched your search
@@ -46,7 +46,7 @@ export function TopologyView() {
|
|||||||
// 编辑节点 Dialog
|
// 编辑节点 Dialog
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [editingNode, setEditingNode] = useState<TopologyNode | null>(null);
|
const [editingNode, setEditingNode] = useState<TopologyNode | null>(null);
|
||||||
const [editForm, setEditForm] = useState({ name: '', type: 'database', note: '' });
|
const [editForm, setEditForm] = useState<{ name: string; type: TopologyNode['type']; note: string }>({ name: '', type: 'database', note: '' });
|
||||||
|
|
||||||
const loadClusters = useCallback(async () => {
|
const loadClusters = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -552,7 +552,7 @@ export function TopologyView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-slate-300">类型</Label>
|
<Label className="text-slate-300">类型</Label>
|
||||||
<Select value={editForm.type} onValueChange={(v) => setEditForm({ ...editForm, type: v })}>
|
<Select value={editForm.type} onValueChange={(v) => setEditForm({ ...editForm, type: v as TopologyNode['type'] })}>
|
||||||
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent className="bg-slate-800 border-slate-700">
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
{CUSTOM_NODE_TYPES.map((t) => <SelectItem key={t.value} value={t.value}>{t.label} — {t.desc}</SelectItem>)}
|
{CUSTOM_NODE_TYPES.map((t) => <SelectItem key={t.value} value={t.value}>{t.label} — {t.desc}</SelectItem>)}
|
||||||
|
|||||||
@@ -1,98 +1,38 @@
|
|||||||
|
// 告警中心主组件 // Logan.Li
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Bell, AlertTriangle, AlertCircle, CheckCircle, XCircle, Clock, Filter,
|
AlertTriangle, AlertCircle, CheckCircle, Zap, Search, RefreshCw,
|
||||||
Search, RefreshCw, Settings, Eye, Check, X, Volume2, VolumeX, Mail,
|
Eye, Check, VolumeX,
|
||||||
MessageSquare, Phone, User, Server, TrendingUp, TrendingDown, BarChart3,
|
|
||||||
Calendar, Download, MoreHorizontal, ChevronDown, ChevronUp, Zap, Shield,
|
|
||||||
Activity, Layers, Info, Plus, ChevronRight
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
import { alertCenterApi } from '@/services/api';
|
||||||
import { alertCenterApi, alertApi } from '@/services/api';
|
import {
|
||||||
|
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
|
||||||
|
} from './alert-center-helpers';
|
||||||
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
||||||
|
import { AlertChannelsTab } from './AlertChannelsTab';
|
||||||
export 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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logan.Li — 字段名与后端 alert.go AlertRule 对齐
|
|
||||||
interface AlertRule {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
severity: string;
|
|
||||||
type?: string;
|
|
||||||
source?: string;
|
|
||||||
expression?: string;
|
|
||||||
duration?: number;
|
|
||||||
threshold?: number;
|
|
||||||
labels?: string;
|
|
||||||
annotations?: string;
|
|
||||||
notify_channels?: string;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationChannel {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
enabled: boolean;
|
|
||||||
config: string;
|
|
||||||
test_status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AlertCenter: React.FC = () => {
|
const AlertCenter: React.FC = () => {
|
||||||
const _confirm = useConfirm();
|
|
||||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
const [severityFilter, setSeverityFilter] = useState('all');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [activeTab, setActiveTab] = useState('alerts');
|
const [activeTab, setActiveTab] = useState('alerts');
|
||||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [silenceOpen, setSilenceOpen] = useState(false);
|
const [silenceOpen, setSilenceOpen] = useState(false);
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({ firing: 0, critical: 0, warning: 0, resolved: 0 });
|
||||||
firing: 0,
|
|
||||||
critical: 0,
|
|
||||||
warning: 0,
|
|
||||||
resolved: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -101,39 +41,24 @@ const AlertCenter: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
await Promise.all([fetchAlerts(), fetchRules(), fetchChannels()]);
|
await Promise.all([fetchAlerts(), fetchRules()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Logan.Li — 获取告警列表
|
||||||
const fetchAlerts = async () => {
|
const fetchAlerts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await alertCenterApi.getAlerts();
|
const res = await alertCenterApi.getAlerts();
|
||||||
const raw = (res.list || []) as any[];
|
const raw = (res.list || []) as any[];
|
||||||
// Logan.Li — 后端字段映射到前端 Alert 接口
|
const data: Alert[] = raw.map(mapAlert);
|
||||||
const data: Alert[] = raw.map(a => ({
|
|
||||||
id: String(a.id),
|
|
||||||
title: a.rule_name || a.title || `告警 #${a.id}`,
|
|
||||||
description: a.message || a.description || '',
|
|
||||||
severity: a.severity || 'info',
|
|
||||||
status: a.status || 'firing',
|
|
||||||
source: a.target || a.source || '',
|
|
||||||
service: a.service || '',
|
|
||||||
metric: a.metric || '',
|
|
||||||
value: a.value || 0,
|
|
||||||
threshold: a.threshold || 0,
|
|
||||||
startedAt: a.started_at || a.startedAt || a.created_at || new Date().toISOString(),
|
|
||||||
resolvedAt: a.resolved_at || a.resolvedAt,
|
|
||||||
labels: typeof a.labels === 'string' ? (() => { try { return JSON.parse(a.labels || '{}'); } catch { return {}; } })() : (a.labels || {}),
|
|
||||||
annotations: typeof a.annotations === 'string' ? (() => { try { return JSON.parse(a.annotations || '{}'); } catch { return {}; } })() : (a.annotations || {}),
|
|
||||||
}));
|
|
||||||
setAlerts(data);
|
setAlerts(data);
|
||||||
setStats({
|
setStats({
|
||||||
firing: data.filter((a: Alert) => a.status === 'firing').length,
|
firing: data.filter(a => a.status === 'firing').length,
|
||||||
critical: data.filter((a: Alert) => a.severity === 'critical' && a.status === 'firing').length,
|
critical: data.filter(a => a.severity === 'critical' && a.status === 'firing').length,
|
||||||
warning: data.filter((a: Alert) => a.severity === 'warning' && a.status === 'firing').length,
|
warning: data.filter(a => a.severity === 'warning' && a.status === 'firing').length,
|
||||||
resolved: data.filter((a: Alert) => a.status === 'resolved').length
|
resolved: data.filter(a => a.status === 'resolved').length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('获取告警失败');
|
toast.error('获取告警失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -144,27 +69,17 @@ const AlertCenter: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await alertCenterApi.getRules();
|
const res = await alertCenterApi.getRules();
|
||||||
setRules(res.list || []);
|
setRules(res.list || []);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('获取告警规则失败');
|
toast.error('获取告警规则失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchChannels = async () => {
|
|
||||||
try {
|
|
||||||
const res = await alertApi.getNotifyChannels();
|
|
||||||
const list = (res as any)?.list || [];
|
|
||||||
setChannels(list);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('获取通知渠道失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAcknowledge = async (id: string) => {
|
const handleAcknowledge = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await alertCenterApi.acknowledge(id);
|
await alertCenterApi.acknowledge(id);
|
||||||
toast.success('告警已确认');
|
toast.success('告警已确认');
|
||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('操作失败');
|
toast.error('操作失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -174,7 +89,7 @@ const AlertCenter: React.FC = () => {
|
|||||||
await alertCenterApi.resolve(id);
|
await alertCenterApi.resolve(id);
|
||||||
toast.success('告警已解决');
|
toast.success('告警已解决');
|
||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('操作失败');
|
toast.error('操作失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -185,7 +100,7 @@ const AlertCenter: React.FC = () => {
|
|||||||
toast.success('告警已静默');
|
toast.success('告警已静默');
|
||||||
setSilenceOpen(false);
|
setSilenceOpen(false);
|
||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('操作失败');
|
toast.error('操作失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -195,128 +110,11 @@ const AlertCenter: React.FC = () => {
|
|||||||
await alertCenterApi.toggleRule(ruleId);
|
await alertCenterApi.toggleRule(ruleId);
|
||||||
setRules(rules.map(r => String(r.id) === ruleId ? { ...r, enabled } : r));
|
setRules(rules.map(r => String(r.id) === ruleId ? { ...r, enabled } : r));
|
||||||
toast.success('规则状态已更新');
|
toast.success('规则状态已更新');
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('更新失败');
|
toast.error('更新失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 通知渠道管理
|
|
||||||
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
|
|
||||||
const [editingChannel, setEditingChannel] = useState<NotificationChannel | null>(null);
|
|
||||||
const [channelForm, setChannelForm] = useState({ name: '', type: 'dingtalk', config: '' });
|
|
||||||
const [channelSaving, setChannelSaving] = useState(false);
|
|
||||||
|
|
||||||
const openCreateChannel = () => {
|
|
||||||
setEditingChannel(null);
|
|
||||||
setChannelForm({ name: '', type: 'dingtalk', config: '' });
|
|
||||||
setChannelDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditChannel = (ch: NotificationChannel) => {
|
|
||||||
setEditingChannel(ch);
|
|
||||||
setChannelForm({ name: ch.name, type: ch.type, config: ch.config || '' });
|
|
||||||
setChannelDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveChannel = async () => {
|
|
||||||
if (!channelForm.name.trim()) return;
|
|
||||||
setChannelSaving(true);
|
|
||||||
try {
|
|
||||||
const payload = { name: channelForm.name.trim(), type: channelForm.type, config: channelForm.config.trim() };
|
|
||||||
if (editingChannel) {
|
|
||||||
await alertApi.updateNotifyChannel(editingChannel.id, payload);
|
|
||||||
toast.success('渠道已更新');
|
|
||||||
} else {
|
|
||||||
await alertApi.createNotifyChannel(payload);
|
|
||||||
toast.success('渠道已添加');
|
|
||||||
}
|
|
||||||
setChannelDialogOpen(false);
|
|
||||||
fetchChannels();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('保存失败');
|
|
||||||
} finally {
|
|
||||||
setChannelSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteChannel = async (id: number) => {
|
|
||||||
{ const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
|
||||||
try {
|
|
||||||
await alertApi.deleteNotifyChannel(id);
|
|
||||||
toast.success('渠道已删除');
|
|
||||||
fetchChannels();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestChannel = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const res = await alertApi.testNotifyChannel(id);
|
|
||||||
const status = (res as any)?.status;
|
|
||||||
if (status === 'success') {
|
|
||||||
toast.success('测试通知已发送');
|
|
||||||
} else {
|
|
||||||
toast.warning('渠道未配置参数,请先完善配置');
|
|
||||||
}
|
|
||||||
fetchChannels();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('测试失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleChannel = async (ch: NotificationChannel) => {
|
|
||||||
try {
|
|
||||||
await alertApi.updateNotifyChannel(ch.id, { enabled: !ch.enabled });
|
|
||||||
fetchChannels();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('操作失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getChannelTypeLabel = (type: string) => {
|
|
||||||
const map: Record<string, string> = { dingtalk: '钉钉', wechat: '企业微信', email: '邮件', sms: '短信', webhook: 'Webhook' };
|
|
||||||
return map[type] || type;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 filteredAlerts = alerts.filter(alert => {
|
||||||
const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
alert.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
alert.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@@ -391,8 +189,8 @@ const AlertCenter: React.FC = () => {
|
|||||||
<TabsTrigger value="channels">通知渠道</TabsTrigger>
|
<TabsTrigger value="channels">通知渠道</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 告警列表 Tab */}
|
||||||
<TabsContent value="alerts" className="space-y-4">
|
<TabsContent value="alerts" className="space-y-4">
|
||||||
{/* 过滤栏 */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-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="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -427,12 +225,10 @@ const AlertCenter: React.FC = () => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button variant="outline" onClick={fetchAlerts}>
|
||||||
<Button variant="outline" onClick={fetchAlerts}>
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
刷新
|
||||||
刷新
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 告警列表 */}
|
{/* 告警列表 */}
|
||||||
@@ -452,13 +248,9 @@ const AlertCenter: React.FC = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">加载中...</td></tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">加载中...</td>
|
|
||||||
</tr>
|
|
||||||
) : filteredAlerts.length === 0 ? (
|
) : filteredAlerts.length === 0 ? (
|
||||||
<tr>
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无告警</td></tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无告警</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
filteredAlerts.map((alert) => (
|
filteredAlerts.map((alert) => (
|
||||||
<tr key={alert.id} className="hover:bg-muted/50">
|
<tr key={alert.id} className="hover:bg-muted/50">
|
||||||
@@ -480,41 +272,28 @@ const AlertCenter: React.FC = () => {
|
|||||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
{alert.status === 'resolved' && alert.resolvedAt
|
{alert.status === 'resolved' && alert.resolvedAt
|
||||||
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
|
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
|
||||||
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
|
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分钟`}
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="sm"
|
||||||
variant="ghost"
|
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
|
||||||
size="sm"
|
|
||||||
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{alert.status === 'firing' && (
|
{alert.status === 'firing' && (
|
||||||
<Button
|
<Button variant="ghost" size="sm"
|
||||||
variant="ghost"
|
onClick={() => handleAcknowledge(alert.id)}>
|
||||||
size="sm"
|
|
||||||
onClick={() => handleAcknowledge(alert.id)}
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4 text-blue-600" />
|
<Check className="w-4 h-4 text-blue-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
|
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
|
||||||
<Button
|
<Button variant="ghost" size="sm"
|
||||||
variant="ghost"
|
onClick={() => handleResolve(alert.id)}>
|
||||||
size="sm"
|
|
||||||
onClick={() => handleResolve(alert.id)}
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button variant="ghost" size="sm"
|
||||||
variant="ghost"
|
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
|
||||||
size="sm"
|
|
||||||
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}
|
|
||||||
>
|
|
||||||
<VolumeX className="w-4 h-4 text-orange-600" />
|
<VolumeX className="w-4 h-4 text-orange-600" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -529,14 +308,13 @@ const AlertCenter: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 告警规则 Tab */}
|
||||||
<TabsContent value="rules" className="space-y-4">
|
<TabsContent value="rules" className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex items-center gap-2">
|
<Button variant="outline" onClick={fetchRules}>
|
||||||
<Button variant="outline" onClick={fetchRules}>
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
刷新
|
||||||
刷新
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
@@ -559,15 +337,15 @@ const AlertCenter: React.FC = () => {
|
|||||||
{rule.duration ? <span>持续 {rule.duration}秒</span> : null}
|
{rule.duration ? <span>持续 {rule.duration}秒</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
let channels: string[] = [];
|
let chs: string[] = [];
|
||||||
try {
|
try {
|
||||||
const raw = rule.notify_channels;
|
const raw = rule.notify_channels;
|
||||||
if (raw) channels = Array.isArray(raw) ? raw : JSON.parse(raw);
|
if (raw) chs = Array.isArray(raw) ? raw : JSON.parse(raw);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
if (!channels.length) return null;
|
if (!chs.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
{channels.map(ch => (
|
{chs.map(ch => (
|
||||||
<Badge key={ch} variant="outline" className="text-xs">{ch}</Badge>
|
<Badge key={ch} variant="outline" className="text-xs">{ch}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -587,63 +365,9 @@ const AlertCenter: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="channels" className="space-y-4">
|
{/* 通知渠道 Tab — 自包含子组件 */}
|
||||||
<div className="flex justify-between items-center">
|
<TabsContent value="channels">
|
||||||
<div className="flex items-center gap-2">
|
<AlertChannelsTab refreshKey={0} />
|
||||||
<Button variant="outline" onClick={fetchChannels}>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button onClick={openCreateChannel}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
添加渠道
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{channels.length === 0 ? (
|
|
||||||
<div className="text-center py-16 text-muted-foreground border border-dashed rounded-lg">
|
|
||||||
暂无通知渠道,点击「添加渠道」创建
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<Card key={channel.id} className="cursor-pointer hover:border-primary/50 transition-colors" >
|
|
||||||
<CardHeader className="pb-3" onClick={() => openEditChannel(channel)}>
|
|
||||||
<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'} className={channel.enabled ? 'bg-green-600' : ''}>
|
|
||||||
{channel.enabled ? '已启用' : '已禁用'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="text-sm text-muted-foreground mb-3">
|
|
||||||
类型: {getChannelTypeLabel(channel.type)}
|
|
||||||
{channel.test_status === 'success' && (
|
|
||||||
<span className="ml-2 text-green-500">测试通过</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleTestChannel(channel.id)}>
|
|
||||||
<Zap className="w-4 h-4 mr-1.5" />
|
|
||||||
测试
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleToggleChannel(channel)}>
|
|
||||||
<Settings className="w-4 h-4 mr-1.5" />
|
|
||||||
{channel.enabled ? '禁用' : '启用'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => handleDeleteChannel(channel.id)}>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -659,63 +383,6 @@ const AlertCenter: React.FC = () => {
|
|||||||
onOpenChange={setSilenceOpen}
|
onOpenChange={setSilenceOpen}
|
||||||
onSilence={handleSilence}
|
onSilence={handleSilence}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 通知渠道创建/编辑对话框 */}
|
|
||||||
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editingChannel ? '编辑通知渠道' : '添加通知渠道'}</DialogTitle>
|
|
||||||
<DialogDescription>配置告警通知渠道(钉钉/企业微信/邮件/短信/Webhook)</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>渠道名称 *</Label>
|
|
||||||
<Input
|
|
||||||
value={channelForm.name}
|
|
||||||
onChange={(e) => setChannelForm({ ...channelForm, name: e.target.value })}
|
|
||||||
placeholder="如:运维告警群"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>渠道类型</Label>
|
|
||||||
<Select value={channelForm.type} onValueChange={(v) => setChannelForm({ ...channelForm, type: v })}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="dingtalk">钉钉</SelectItem>
|
|
||||||
<SelectItem value="wechat">企业微信</SelectItem>
|
|
||||||
<SelectItem value="email">邮件</SelectItem>
|
|
||||||
<SelectItem value="sms">短信</SelectItem>
|
|
||||||
<SelectItem value="webhook">Webhook</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>配置(JSON)</Label>
|
|
||||||
<Input
|
|
||||||
value={channelForm.config}
|
|
||||||
onChange={(e) => setChannelForm({ ...channelForm, config: e.target.value })}
|
|
||||||
placeholder='{"webhook":"https://...","secret":"..."}'
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{channelForm.type === 'dingtalk' && '钉钉机器人: webhook URL + secret'}
|
|
||||||
{channelForm.type === 'wechat' && '企业微信: webhook URL'}
|
|
||||||
{channelForm.type === 'email' && '邮件: {"to":"ops@ezops.io"}'}
|
|
||||||
{channelForm.type === 'sms' && '短信: {"phones":"13800138000"}'}
|
|
||||||
{channelForm.type === 'webhook' && 'Webhook: {"url":"https://..."}'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setChannelDialogOpen(false)}>取消</Button>
|
|
||||||
<Button onClick={handleSaveChannel} disabled={!channelForm.name.trim() || channelSaving}>
|
|
||||||
{channelSaving ? '保存中...' : editingChannel ? '保存修改' : '添加渠道'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
import {
|
import { Check, ChevronRight } from 'lucide-react';
|
||||||
Check, ChevronRight, AlertCircle, AlertTriangle, Info,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import type { Alert } from './AlertCenter';
|
import { type Alert, getSeverityBadge } from './alert-center-helpers'; // Logan.Li — 统一从 helpers 导入
|
||||||
|
|
||||||
function 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>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlertDetailDialog({
|
export function AlertDetailDialog({
|
||||||
alert,
|
alert,
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// 通知渠道创建/编辑对话框 // Logan.Li
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { alertApi } from '@/services/api';
|
||||||
|
import type { NotificationChannel } from './alert-center-helpers';
|
||||||
|
|
||||||
|
const CHANNEL_TYPES = [
|
||||||
|
{ value: 'dingtalk', label: '钉钉', hint: '钉钉机器人: webhook URL + secret' },
|
||||||
|
{ value: 'wechat', label: '企业微信', hint: '企业微信: webhook URL' },
|
||||||
|
{ value: 'email', label: '邮件', hint: '邮件: {"to":"ops@ezops.io"}' },
|
||||||
|
{ value: 'sms', label: '短信', hint: '短信: {"phones":"13800138000"}' },
|
||||||
|
{ value: 'webhook', label: 'Webhook', hint: 'Webhook: {"url":"https://..."}' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AlertChannelDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editingChannel,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
editingChannel: NotificationChannel | null;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({ name: '', type: 'dingtalk', config: '' });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Logan.Li — 对话框打开时同步编辑数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (editingChannel) {
|
||||||
|
setForm({ name: editingChannel.name, type: editingChannel.type, config: editingChannel.config || '' });
|
||||||
|
} else {
|
||||||
|
setForm({ name: '', type: 'dingtalk', config: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, editingChannel]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = { name: form.name.trim(), type: form.type, config: form.config.trim() };
|
||||||
|
if (editingChannel) {
|
||||||
|
await alertApi.updateNotifyChannel(editingChannel.id, payload);
|
||||||
|
toast.success('渠道已更新');
|
||||||
|
} else {
|
||||||
|
await alertApi.createNotifyChannel(payload);
|
||||||
|
toast.success('渠道已添加');
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
onSaved();
|
||||||
|
} catch {
|
||||||
|
toast.error('保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentHint = CHANNEL_TYPES.find(t => t.value === form.type)?.hint || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingChannel ? '编辑通知渠道' : '添加通知渠道'}</DialogTitle>
|
||||||
|
<DialogDescription>配置告警通知渠道(钉钉/企业微信/邮件/短信/Webhook)</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>渠道名称 *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="如:运维告警群"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>渠道类型</Label>
|
||||||
|
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CHANNEL_TYPES.map(t => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>配置(JSON)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.config}
|
||||||
|
onChange={(e) => setForm({ ...form, config: e.target.value })}
|
||||||
|
placeholder='{"webhook":"https://...","secret":"..."}'
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{currentHint}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!form.name.trim() || saving}>
|
||||||
|
{saving ? '保存中...' : editingChannel ? '保存修改' : '添加渠道'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// 通知渠道管理 Tab(自包含:状态+API+渲染) // Logan.Li
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Plus, RefreshCw, Zap, Settings, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
|
import { alertApi } from '@/services/api';
|
||||||
|
import {
|
||||||
|
type NotificationChannel, getChannelIcon, getChannelTypeLabel,
|
||||||
|
} from './alert-center-helpers';
|
||||||
|
import { AlertChannelDialog } from './AlertChannelDialog';
|
||||||
|
|
||||||
|
export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey }) => {
|
||||||
|
const _confirm = useConfirm();
|
||||||
|
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingChannel, setEditingChannel] = useState<NotificationChannel | null>(null);
|
||||||
|
|
||||||
|
// Logan.Li — 获取渠道列表
|
||||||
|
const fetchChannels = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await alertApi.getNotifyChannels();
|
||||||
|
const list = (res as any)?.list || [];
|
||||||
|
setChannels(list);
|
||||||
|
} catch {
|
||||||
|
toast.error('获取通知渠道失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchChannels(); }, [fetchChannels, refreshKey]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingChannel(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (ch: NotificationChannel) => {
|
||||||
|
setEditingChannel(ch);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' });
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await alertApi.deleteNotifyChannel(id);
|
||||||
|
toast.success('渠道已删除');
|
||||||
|
fetchChannels();
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const res = await alertApi.testNotifyChannel(id);
|
||||||
|
const status = (res as any)?.status;
|
||||||
|
if (status === 'success') {
|
||||||
|
toast.success('测试通知已发送');
|
||||||
|
} else {
|
||||||
|
toast.warning('渠道未配置参数,请先完善配置');
|
||||||
|
}
|
||||||
|
fetchChannels();
|
||||||
|
} catch {
|
||||||
|
toast.error('测试失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (ch: NotificationChannel) => {
|
||||||
|
try {
|
||||||
|
await alertApi.updateNotifyChannel(ch.id, { enabled: !ch.enabled });
|
||||||
|
fetchChannels();
|
||||||
|
} catch {
|
||||||
|
toast.error('操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Button variant="outline" onClick={fetchChannels}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
添加渠道
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-16 text-muted-foreground">加载中...</div>
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-muted-foreground border border-dashed rounded-lg">
|
||||||
|
暂无通知渠道,点击「添加渠道」创建
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<Card key={channel.id} className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||||
|
<CardHeader className="pb-3" onClick={() => openEdit(channel)}>
|
||||||
|
<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'} className={channel.enabled ? 'bg-green-600' : ''}>
|
||||||
|
{channel.enabled ? '已启用' : '已禁用'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="text-sm text-muted-foreground mb-3">
|
||||||
|
类型: {getChannelTypeLabel(channel.type)}
|
||||||
|
{channel.test_status === 'success' && (
|
||||||
|
<span className="ml-2 text-green-500">测试通过</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleTest(channel.id)}>
|
||||||
|
<Zap className="w-4 h-4 mr-1.5" />
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleToggle(channel)}>
|
||||||
|
<Settings className="w-4 h-4 mr-1.5" />
|
||||||
|
{channel.enabled ? '禁用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleDelete(channel.id)}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertChannelDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
editingChannel={editingChannel}
|
||||||
|
onSaved={fetchChannels}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// 告警中心共享类型与辅助函数 // Logan.Li
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
AlertCircle, AlertTriangle, Info, Zap, User, CheckCircle, VolumeX,
|
||||||
|
Mail, MessageSquare, Phone, Bell,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
/** 前端 Alert 接口 */
|
||||||
|
export 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logan.Li — 字段名与后端 alert.go AlertRule 对齐
|
||||||
|
export interface AlertRule {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
severity: string;
|
||||||
|
type?: string;
|
||||||
|
source?: string;
|
||||||
|
expression?: string;
|
||||||
|
duration?: number;
|
||||||
|
threshold?: number;
|
||||||
|
labels?: string;
|
||||||
|
annotations?: string;
|
||||||
|
notify_channels?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationChannel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
config: string;
|
||||||
|
test_status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logan.Li — 后端字段映射到前端 Alert 接口
|
||||||
|
export function mapAlert(a: any): Alert {
|
||||||
|
return {
|
||||||
|
id: String(a.id),
|
||||||
|
title: a.rule_name || a.title || `告警 #${a.id}`,
|
||||||
|
description: a.message || a.description || '',
|
||||||
|
severity: a.severity || 'info',
|
||||||
|
status: a.status || 'firing',
|
||||||
|
source: a.target || a.source || '',
|
||||||
|
service: a.service || '',
|
||||||
|
metric: a.metric || '',
|
||||||
|
value: a.value || 0,
|
||||||
|
threshold: a.threshold || 0,
|
||||||
|
startedAt: a.started_at || a.startedAt || a.created_at || new Date().toISOString(),
|
||||||
|
resolvedAt: a.resolved_at || a.resolvedAt,
|
||||||
|
labels: typeof a.labels === 'string'
|
||||||
|
? (() => { try { return JSON.parse(a.labels || '{}'); } catch { return {}; } })()
|
||||||
|
: (a.labels || {}),
|
||||||
|
annotations: typeof a.annotations === 'string'
|
||||||
|
? (() => { try { return JSON.parse(a.annotations || '{}'); } catch { return {}; } })()
|
||||||
|
: (a.annotations || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 严重级别 Badge */
|
||||||
|
export function 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 告警状态 Badge */
|
||||||
|
export function 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渠道类型图标 */
|
||||||
|
export function 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" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渠道类型中文标签 */
|
||||||
|
export function getChannelTypeLabel(type: string) {
|
||||||
|
const map: Record<string, string> = { dingtalk: '钉钉', wechat: '企业微信', email: '邮件', sms: '短信', webhook: 'Webhook' };
|
||||||
|
return map[type] || type;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user