diff --git a/agent/main.go b/agent/main.go index eeb2ba9..3396b0e 100644 --- a/agent/main.go +++ b/agent/main.go @@ -32,7 +32,7 @@ import ( ) var ( - version = "dev" + version = "v2.0.0" buildTime = "unknown" ) diff --git a/backend/agent/bin/aiops-agent-linux-amd64 b/backend/agent/bin/aiops-agent-linux-amd64 index fdc04be..e136f11 100755 Binary files a/backend/agent/bin/aiops-agent-linux-amd64 and b/backend/agent/bin/aiops-agent-linux-amd64 differ diff --git a/backend/internal/api/handlers/alert_contacts.go b/backend/internal/api/handlers/alert_contacts.go new file mode 100644 index 0000000..cd63c7b --- /dev/null +++ b/backend/internal/api/handlers/alert_contacts.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +// 告警联系人 + 联系组 CRUD(AlertHandler 方法接收者) // Logan.Li + +// ==================== 联系人 ==================== + +// ListContacts GET /alerts/contacts +func (h *AlertHandler) ListContacts(c *gin.Context) { + var contacts []models.AlertContact + h.db.Order("created_at DESC").Find(&contacts) + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{List: contacts, Total: int64(len(contacts))}, + }) +} + +// CreateContact POST /alerts/contacts +func (h *AlertHandler) CreateContact(c *gin.Context) { + var req models.CreateContactRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + contact := models.AlertContact{ + Name: req.Name, + Phone: req.Phone, + Email: req.Email, + Dingtalk: req.Dingtalk, + Feishu: req.Feishu, + Wechat: req.Wechat, + } + if err := h.db.Create(&contact).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: contact}) +} + +// UpdateContact PUT /alerts/contacts/:id +func (h *AlertHandler) UpdateContact(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req models.UpdateContactRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"}) + return + } + var contact models.AlertContact + if err := h.db.First(&contact, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "联系人不存在"}) + return + } + updates := map[string]interface{}{} + if req.Name != "" { updates["name"] = req.Name } + if req.Phone != "" { updates["phone"] = req.Phone } + if req.Email != "" { updates["email"] = req.Email } + if req.Dingtalk != "" { updates["dingtalk"] = req.Dingtalk } + if req.Feishu != "" { updates["feishu"] = req.Feishu } + if req.Wechat != "" { updates["wechat"] = req.Wechat } + h.db.Model(&contact).Updates(updates) + h.db.First(&contact, id) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: contact}) +} + +// DeleteContact DELETE /alerts/contacts/:id +func (h *AlertHandler) DeleteContact(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := h.db.Delete(&models.AlertContact{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} + +// ==================== 联系组 ==================== + +// ListContactGroups GET /alerts/contact-groups +func (h *AlertHandler) ListContactGroups(c *gin.Context) { + var groups []models.AlertContactGroup + h.db.Order("created_at DESC").Find(&groups) + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{List: groups, Total: int64(len(groups))}, + }) +} + +// CreateContactGroup POST /alerts/contact-groups +func (h *AlertHandler) CreateContactGroup(c *gin.Context) { + var req models.CreateContactGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + idsJSON, _ := json.Marshal(req.ContactIDs) + group := models.AlertContactGroup{ + GroupName: req.GroupName, + Notes: req.Notes, + ContactIDs: string(idsJSON), + } + if err := h.db.Create(&group).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: group}) +} + +// UpdateContactGroup PUT /alerts/contact-groups/:id +func (h *AlertHandler) UpdateContactGroup(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req models.UpdateContactGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"}) + return + } + var group models.AlertContactGroup + if err := h.db.First(&group, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "联系组不存在"}) + return + } + updates := map[string]interface{}{} + if req.GroupName != "" { updates["group_name"] = req.GroupName } + if req.Notes != "" { updates["notes"] = req.Notes } + if len(req.ContactIDs) > 0 { + idsJSON, _ := json.Marshal(req.ContactIDs) + updates["contact_ids"] = string(idsJSON) + } + h.db.Model(&group).Updates(updates) + h.db.First(&group, id) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: group}) +} + +// DeleteContactGroup DELETE /alerts/contact-groups/:id +func (h *AlertHandler) DeleteContactGroup(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := h.db.Delete(&models.AlertContactGroup{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 899aaa2..086a60c 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -455,6 +455,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c alerts.DELETE("/notify-channels/:id", alertHandler.DeleteNotifyChannel) alerts.POST("/notify-channels/:id/test", alertHandler.TestNotifyChannel) alerts.POST("/quick-setup", alertHandler.QuickSetupRules) + // 报警联系人 + alerts.GET("/contacts", alertHandler.ListContacts) + alerts.POST("/contacts", alertHandler.CreateContact) + alerts.PUT("/contacts/:id", alertHandler.UpdateContact) + alerts.DELETE("/contacts/:id", alertHandler.DeleteContact) + // 报警联系组 + alerts.GET("/contact-groups", alertHandler.ListContactGroups) + alerts.POST("/contact-groups", alertHandler.CreateContactGroup) + alerts.PUT("/contact-groups/:id", alertHandler.UpdateContactGroup) + alerts.DELETE("/contact-groups/:id", alertHandler.DeleteContactGroup) } // 流水线路由 diff --git a/backend/internal/models/alert.go b/backend/internal/models/alert.go index c9289aa..4b0252f 100644 --- a/backend/internal/models/alert.go +++ b/backend/internal/models/alert.go @@ -395,6 +395,79 @@ type AcknowledgeAlertRequest struct { Message string `json:"message"` } +// ============================================ +// AlertContact 报警联系人 +// ============================================ + +type AlertContact struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:联系人姓名" json:"name"` + Phone string `gorm:"type:varchar(32);comment:手机号" json:"phone,omitempty"` + Email string `gorm:"type:varchar(128);comment:邮箱" json:"email,omitempty"` + Dingtalk string `gorm:"type:varchar(256);comment:钉钉webhook地址" json:"dingtalk,omitempty"` + Feishu string `gorm:"type:varchar(256);comment:飞书webhook地址" json:"feishu,omitempty"` + Wechat string `gorm:"type:varchar(256);comment:企业微信webhook地址" json:"wechat,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` +} + +func (AlertContact) TableName() string { + return "monitor_alert_contacts" +} + +// ============================================ +// AlertContactGroup 报警联系组 +// ============================================ + +type AlertContactGroup struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + GroupName string `gorm:"type:varchar(128);not null;comment:组名称" json:"group_name"` + Notes string `gorm:"type:varchar(500);comment:备注" json:"notes,omitempty"` + ContactIDs string `gorm:"type:text;comment:联系人ID列表JSON" json:"contact_ids,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` +} + +func (AlertContactGroup) TableName() string { + return "monitor_alert_contact_groups" +} + +// CreateContactRequest 创建联系人请求 +type CreateContactRequest struct { + Name string `json:"name" binding:"required"` + Phone string `json:"phone,omitempty"` + Email string `json:"email,omitempty"` + Dingtalk string `json:"dingtalk,omitempty"` + Feishu string `json:"feishu,omitempty"` + Wechat string `json:"wechat,omitempty"` +} + +// UpdateContactRequest 更新联系人请求 +type UpdateContactRequest struct { + Name string `json:"name,omitempty"` + Phone string `json:"phone,omitempty"` + Email string `json:"email,omitempty"` + Dingtalk string `json:"dingtalk,omitempty"` + Feishu string `json:"feishu,omitempty"` + Wechat string `json:"wechat,omitempty"` +} + +// CreateContactGroupRequest 创建联系组请求 +type CreateContactGroupRequest struct { + GroupName string `json:"group_name" binding:"required"` + Notes string `json:"notes,omitempty"` + ContactIDs []uint `json:"contact_ids"` +} + +// UpdateContactGroupRequest 更新联系组请求 +type UpdateContactGroupRequest struct { + GroupName string `json:"group_name,omitempty"` + Notes string `json:"notes,omitempty"` + ContactIDs []uint `json:"contact_ids,omitempty"` +} + // CreateNotifyChannelRequest 创建通知渠道请求 type CreateNotifyChannelRequest struct { Name string `json:"name" binding:"required"` @@ -426,7 +499,7 @@ type FeishuCardContent struct { Title string `json:"title"` Content [][]FeishuCardElement `json:"content"` } - +// FeishuCardElement 飞书卡片元素 type FeishuCardElement struct { Tag string `json:"tag"` Text string `json:"text,omitempty"` diff --git a/frontend/src/components/monitor/alert-center/AlertCenter.tsx b/frontend/src/components/monitor/alert-center/AlertCenter.tsx index 1a4d90e..0cfc92b 100644 --- a/frontend/src/components/monitor/alert-center/AlertCenter.tsx +++ b/frontend/src/components/monitor/alert-center/AlertCenter.tsx @@ -7,7 +7,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent } from '@/components/ui/card'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; @@ -15,13 +15,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; import { useConfirm } from '@/components/ui/confirm-dialog'; -import { alertCenterApi, agentApi } from '@/services/api'; +import { alertCenterApi, agentApi, alertApi } from '@/services/api'; import { type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge, } from './alert-center-helpers'; import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail'; import { AlertChannelsTab } from './AlertChannelsTab'; import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog'; +import { ContactDialog } from './ContactDialog'; +import { ContactGroupDialog } from './ContactGroupDialog'; +import type { Contact, ContactGroup } from './alert-center-helpers'; const AlertCenter: React.FC = () => { const _confirm = useConfirm(); @@ -40,6 +43,13 @@ const AlertCenter: React.FC = () => { const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [editingRule, setEditingRule] = useState(null); const [quickSetupLoading, setQuickSetupLoading] = useState(false); + // 联系人 & 联系组 + const [contacts, setContacts] = useState([]); + const [contactGroups, setContactGroups] = useState([]); + const [contactDialogOpen, setContactDialogOpen] = useState(false); + const [editingContact, setEditingContact] = useState(null); + const [groupDialogOpen, setGroupDialogOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); // 分页 const [page, setPage] = useState(1); const [pageSize] = useState(10); @@ -48,9 +58,11 @@ const AlertCenter: React.FC = () => { useEffect(() => { fetchData(); + if (activeTab === 'contacts') fetchContacts(); + if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); } const interval = setInterval(fetchData, 30000); return () => clearInterval(interval); - }, [page, severityFilter, statusFilter]); + }, [page, severityFilter, statusFilter, activeTab]); const fetchData = async () => { await Promise.all([fetchAlerts(), fetchRules()]); @@ -103,6 +115,20 @@ const AlertCenter: React.FC = () => { } }; + const fetchContacts = async () => { + try { + const res: any = await alertApi.getContacts(); + setContacts((res?.data?.list || res?.list || []) as Contact[]); + } catch { /* silent */ } + }; + + const fetchContactGroups = async () => { + try { + const res: any = await alertApi.getContactGroups(); + setContactGroups((res?.data?.list || res?.list || []) as ContactGroup[]); + } catch { /* silent */ } + }; + const handleAcknowledge = async (id: string) => { try { await alertCenterApi.acknowledge(id); @@ -151,13 +177,13 @@ const AlertCenter: React.FC = () => { const handleQuickSetup = async () => { const ok = await _confirm({ title: '一键报警', - description: '将为 Agent 在线的全部主机创建 CPU使用率、内存使用率、磁盘使用率、网络带宽使用率 四条默认告警规则(阈值 80%,连续 5 次触发)。', + description: '为 Agent 在线主机创建 CPU(>80%)/内存(>80%)/磁盘(>90%)/TIME_WAIT(>15000)/CLOSE_WAIT(>200)/进程检测 6 条告警规则。', confirmText: '确认创建', }); if (!ok) return; setQuickSetupLoading(true); try { - const res = await agentApi.quickSetup('ecs'); + const res = await alertCenterApi.quickSetup('ecs'); toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功')); fetchRules(); } catch { @@ -236,10 +262,12 @@ const AlertCenter: React.FC = () => { - + 告警列表 告警规则 通知渠道 + 报警联系人 + 报警联系组 {/* 告警列表 Tab */} @@ -478,6 +506,121 @@ const AlertCenter: React.FC = () => { + + {/* 报警联系人 Tab */} + +
+ + +
+ {contacts.length === 0 ? ( +
+ 暂无报警联系人,点击「添加联系人」创建 +
+ ) : ( +
+ {contacts.map((c) => ( + + +
+ {c.name} +
+
+ + {c.phone &&
📱 {c.phone}
} + {c.email &&
📧 {c.email}
} + {c.dingtalk &&
🔷 钉钉已配置
} + {c.feishu &&
🟢 飞书已配置
} + {c.wechat &&
💚 企业微信已配置
} +
+ + +
+
+
+ ))} +
+ )} +
+ + {/* 报警联系组 Tab */} + +
+ + +
+ {contactGroups.length === 0 ? ( +
+ 暂无报警联系组,点击「添加联系组」创建 +
+ ) : ( +
+ {contactGroups.map((g) => { + let cids: number[] = []; + try { cids = JSON.parse(g.contact_ids || '[]'); } catch { /* ignore */ } + const memberNames = contacts + .filter(c => cids.includes(c.id)) + .map(c => c.name); + return ( + + +
+ {g.group_name} + {cids.length} 人 +
+
+ + {g.notes &&

{g.notes}

} +
+ {memberNames.map(name => ( + {name} + ))} +
+
+ + +
+
+
+ ); + })} +
+ )} +
{ notifyChannels={[]} onSaved={() => { handleRuleDialogClose(); fetchRules(); }} /> + + { fetchContactGroups(); }} + /> ); }; diff --git a/frontend/src/components/monitor/alert-center/ContactDialog.tsx b/frontend/src/components/monitor/alert-center/ContactDialog.tsx new file mode 100644 index 0000000..ce40f4a --- /dev/null +++ b/frontend/src/components/monitor/alert-center/ContactDialog.tsx @@ -0,0 +1,143 @@ +// 报警联系人创建/编辑对话框 // 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 { toast } from 'sonner'; +import { alertApi } from '@/services/api'; +import type { Contact } from './alert-center-helpers'; + +export function ContactDialog({ + open, + onOpenChange, + editingContact, + onSaved, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + editingContact: Contact | null; + onSaved: () => void; +}) { + const [form, setForm] = useState({ + name: '', phone: '', email: '', dingtalk: '', feishu: '', wechat: '', + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + if (editingContact) { + setForm({ + name: editingContact.name || '', + phone: editingContact.phone || '', + email: editingContact.email || '', + dingtalk: editingContact.dingtalk || '', + feishu: editingContact.feishu || '', + wechat: editingContact.wechat || '', + }); + } else { + setForm({ name: '', phone: '', email: '', dingtalk: '', feishu: '', wechat: '' }); + } + } + }, [open, editingContact]); + + const handleSave = async () => { + if (!form.name.trim()) return; + setSaving(true); + try { + const payload = { + name: form.name.trim(), + phone: form.phone.trim() || undefined, + email: form.email.trim() || undefined, + dingtalk: form.dingtalk.trim() || undefined, + feishu: form.feishu.trim() || undefined, + wechat: form.wechat.trim() || undefined, + }; + if (editingContact) { + await alertApi.updateContact(editingContact.id, payload); + toast.success('联系人已更新'); + } else { + await alertApi.createContact(payload); + toast.success('联系人已添加'); + } + onOpenChange(false); + onSaved(); + } catch { + toast.error('保存失败'); + } finally { + setSaving(false); + } + }; + + return ( + + + + {editingContact ? '编辑报警联系人' : '添加报警联系人'} + 配置报警联系人信息(姓名必填,渠道选填) + +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="如:张三" + /> +
+
+ + setForm({ ...form, phone: e.target.value })} + placeholder="如:13800138000" + /> +
+
+ + setForm({ ...form, email: e.target.value })} + placeholder="如:zhangsan@example.com" + /> +
+
+ + setForm({ ...form, dingtalk: e.target.value })} + placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." + className="font-mono text-sm" + /> +
+
+ + setForm({ ...form, feishu: e.target.value })} + placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." + className="font-mono text-sm" + /> +
+
+ + setForm({ ...form, wechat: e.target.value })} + placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." + className="font-mono text-sm" + /> +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/monitor/alert-center/ContactGroupDialog.tsx b/frontend/src/components/monitor/alert-center/ContactGroupDialog.tsx new file mode 100644 index 0000000..a1bfcde --- /dev/null +++ b/frontend/src/components/monitor/alert-center/ContactGroupDialog.tsx @@ -0,0 +1,141 @@ +// 报警联系组创建/编辑对话框 // 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 { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { alertApi } from '@/services/api'; +import type { Contact, ContactGroup } from './alert-center-helpers'; + +export function ContactGroupDialog({ + open, + onOpenChange, + editingGroup, + contacts, + onSaved, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + editingGroup: ContactGroup | null; + contacts: Contact[]; + onSaved: () => void; +}) { + const [form, setForm] = useState({ group_name: '', notes: '', contact_ids: [] as number[] }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + if (editingGroup) { + let existingIDs: number[] = []; + try { + existingIDs = JSON.parse(editingGroup.contact_ids || '[]'); + } catch { /* ignore */ } + setForm({ + group_name: editingGroup.group_name || '', + notes: editingGroup.notes || '', + contact_ids: existingIDs, + }); + } else { + setForm({ group_name: '', notes: '', contact_ids: [] }); + } + } + }, [open, editingGroup]); + + const toggleContact = (id: number) => { + setForm(prev => ({ + ...prev, + contact_ids: prev.contact_ids.includes(id) + ? prev.contact_ids.filter(cid => cid !== id) + : [...prev.contact_ids, id], + })); + }; + + const handleSave = async () => { + if (!form.group_name.trim()) return; + setSaving(true); + try { + const payload = { + group_name: form.group_name.trim(), + notes: form.notes.trim() || undefined, + contact_ids: form.contact_ids, + }; + if (editingGroup) { + await alertApi.updateContactGroup(editingGroup.id, payload); + toast.success('联系组已更新'); + } else { + await alertApi.createContactGroup(payload); + toast.success('联系组已添加'); + } + onOpenChange(false); + onSaved(); + } catch { + toast.error('保存失败'); + } finally { + setSaving(false); + } + }; + + return ( + + + + {editingGroup ? '编辑报警联系组' : '添加报警联系组'} + 配置报警联系组,选择组内成员 + +
+
+ + setForm({ ...form, group_name: e.target.value })} + placeholder="如:运维值班组" + /> +
+
+ +