diff --git a/backend/internal/api/handlers/alert.go b/backend/internal/api/handlers/alert.go index 697fdde..f4d1f4b 100644 --- a/backend/internal/api/handlers/alert.go +++ b/backend/internal/api/handlers/alert.go @@ -340,6 +340,200 @@ func (h *AlertHandler) AcknowledgeAlert(c *gin.Context) { }) } +// ListNotifyChannels 获取通知渠道列表 +// @Summary 获取通知渠道列表 +// @Description 获取所有通知渠道列表 +// @Tags 告警 +// @Produce json +// @Security Bearer +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /alerts/notify-channels [get] +func (h *AlertHandler) ListNotifyChannels(c *gin.Context) { + var channels []models.NotifyChannel + h.db.Order("created_at DESC").Find(&channels) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: channels, + Total: int64(len(channels)), + }, + }) +} + +// CreateNotifyChannel 创建通知渠道 +// @Summary 创建通知渠道 +// @Description 创建新通知渠道 +// @Tags 告警 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body models.CreateNotifyChannelRequest true "创建请求" +// @Success 200 {object} models.Response{data=models.NotifyChannel} +// @Router /alerts/notify-channels [post] +func (h *AlertHandler) CreateNotifyChannel(c *gin.Context) { + var req models.CreateNotifyChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + channel := models.NotifyChannel{ + Name: req.Name, + Type: req.Type, + Config: req.Config, + Enabled: true, + } + + if err := h.db.Create(&channel).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: channel, + }) +} + +// UpdateNotifyChannel 更新通知渠道 +// @Summary 更新通知渠道 +// @Description 更新指定通知渠道 +// @Tags 告警 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "渠道ID" +// @Param request body models.UpdateNotifyChannelRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.NotifyChannel} +// @Router /alerts/notify-channels/{id} [put] +func (h *AlertHandler) UpdateNotifyChannel(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var req models.UpdateNotifyChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var channel models.NotifyChannel + if err := h.db.First(&channel, 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.Type != "" { + updates["type"] = req.Type + } + if req.Config != "" { + updates["config"] = req.Config + } + if req.Enabled != nil { + updates["enabled"] = *req.Enabled + } + + if err := h.db.Model(&channel).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + h.db.First(&channel, id) + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: channel, + }) +} + +// DeleteNotifyChannel 删除通知渠道 +// @Summary 删除通知渠道 +// @Description 删除指定通知渠道 +// @Tags 告警 +// @Produce json +// @Security Bearer +// @Param id path int true "渠道ID" +// @Success 200 {object} models.Response +// @Router /alerts/notify-channels/{id} [delete] +func (h *AlertHandler) DeleteNotifyChannel(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.NotifyChannel{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + +// TestNotifyChannel 测试通知渠道 +// @Summary 测试通知渠道连通性 +// @Description 模拟测试通知渠道(返回测试状态) +// @Tags 告警 +// @Produce json +// @Security Bearer +// @Param id path int true "渠道ID" +// @Success 200 {object} models.Response +// @Router /alerts/notify-channels/{id}/test [post] +func (h *AlertHandler) TestNotifyChannel(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var channel models.NotifyChannel + if err := h.db.First(&channel, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "通知渠道不存在", + }) + return + } + + // 模拟测试:校验配置非空即视为成功 + status := "success" + message := "测试通知已发送" + if channel.Config == "" { + status = "warning" + message = "渠道未配置参数,请先完善配置" + } + + h.db.Model(&channel).Update("test_status", status) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: message, + Data: gin.H{ + "status": status, + "channel": channel.Name, + "type": channel.Type, + }, + }) +} + // toJSON serializes a value to JSON string. Returns empty string on error. func toJSON(v interface{}) string { b, err := json.Marshal(v) diff --git a/backend/internal/api/handlers/server.go b/backend/internal/api/handlers/server.go index ca699d4..5b3609d 100644 --- a/backend/internal/api/handlers/server.go +++ b/backend/internal/api/handlers/server.go @@ -469,6 +469,70 @@ func (h *CloudAccountHandler) CreateCloudAccount(c *gin.Context) { }) } +// UpdateCloudAccount 更新云账号 +// @Summary 更新云账号 +// @Description 更新指定云账号信息 +// @Tags 云账号 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "云账号ID" +// @Param request body createCloudAccountRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.CloudAccount} +// @Router /cloud-accounts/{id} [put] +func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req createCloudAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var account models.CloudAccount + if err := h.db.First(&account, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "云账号不存在", + }) + return + } + + // 只在提供非空值时更新凭证字段 + if strings.TrimSpace(req.Name) != "" { + account.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Provider) != "" { + account.Provider = strings.TrimSpace(req.Provider) + } + if strings.TrimSpace(req.AccessKey) != "" { + account.AccessKey = req.AccessKey + } + if strings.TrimSpace(req.SecretKey) != "" { + account.SecretKey = req.SecretKey + } + if strings.TrimSpace(req.Status) != "" { + account.Status = strings.TrimSpace(req.Status) + } + account.MonthlyCost = req.MonthlyCost + + if err := h.db.Save(&account).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: account, + }) +} + // DeleteCloudAccount 删除云账号 // @Summary 删除云账号 // @Description 删除指定云账号 diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 673f269..f16891c 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -93,6 +93,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { { cloudAccounts.GET("", cloudAccountHandler.ListCloudAccounts) cloudAccounts.POST("", cloudAccountHandler.CreateCloudAccount) + cloudAccounts.PUT("/:id", cloudAccountHandler.UpdateCloudAccount) cloudAccounts.DELETE("/:id", cloudAccountHandler.DeleteCloudAccount) } @@ -132,6 +133,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { alerts.DELETE("/rules/:id", alertHandler.DeleteRule) alerts.POST("/rules/:id/toggle", alertHandler.ToggleRule) alerts.POST("/:id/ack", alertHandler.AcknowledgeAlert) + alerts.GET("/notify-channels", alertHandler.ListNotifyChannels) + alerts.POST("/notify-channels", alertHandler.CreateNotifyChannel) + alerts.PUT("/notify-channels/:id", alertHandler.UpdateNotifyChannel) + alerts.DELETE("/notify-channels/:id", alertHandler.DeleteNotifyChannel) + alerts.POST("/notify-channels/:id/test", alertHandler.TestNotifyChannel) } // 流水线路由 diff --git a/backend/internal/models/alert.go b/backend/internal/models/alert.go index 268cdc8..d69073e 100644 --- a/backend/internal/models/alert.go +++ b/backend/internal/models/alert.go @@ -135,3 +135,18 @@ type AlertStats struct { type AcknowledgeAlertRequest struct { Message string `json:"message"` } + +// CreateNotifyChannelRequest 创建通知渠道请求 +type CreateNotifyChannelRequest struct { + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required,oneof=email sms webhook dingtalk wechat"` + Config string `json:"config"` +} + +// UpdateNotifyChannelRequest 更新通知渠道请求 +type UpdateNotifyChannelRequest struct { + Name string `json:"name"` + Type string `json:"type" binding:"omitempty,oneof=email sms webhook dingtalk wechat"` + Config string `json:"config"` + Enabled *bool `json:"enabled"` +} diff --git a/backend/scripts/mock.go b/backend/scripts/mock.go index 47bd692..b58dc23 100644 --- a/backend/scripts/mock.go +++ b/backend/scripts/mock.go @@ -49,6 +49,9 @@ func generateMockData(db *gorm.DB) error { if err := generateAlertRules(db); err != nil { return err } + if err := generateNotifyChannels(db); err != nil { + return err + } if err := generatePipelines(db); err != nil { return err } @@ -368,6 +371,23 @@ func generateAlertRules(db *gorm.DB) error { return nil } +func generateNotifyChannels(db *gorm.DB) error { + channels := []models.NotifyChannel{ + {Name: "运维邮件通知", Type: "email", Config: `{"recipients":["ops@ezops.com","admin@ezops.com"],"subject":"[EZOps告警]"}`, Enabled: true, TestStatus: "success"}, + {Name: "短信通知", Type: "sms", Config: `{"phones":["13800138000","13900139000"],"template":"告警:{{alert_name}}"}`, Enabled: true, TestStatus: "success"}, + {Name: "钉钉机器人", Type: "dingtalk", Config: `{"webhook":"https://oapi.dingtalk.com/robot/send?access_token=eZOps***","secret":"SEC***","at_all":false}`, Enabled: true, TestStatus: "success"}, + {Name: "企业微信机器人", Type: "wechat", Config: `{"webhook":"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=eZOps***"}`, Enabled: true, TestStatus: "success"}, + {Name: "通用Webhook", Type: "webhook", Config: `{"url":"https://api.ezops.com/alerts/webhook","method":"POST","headers":{"Authorization":"Bearer ***"}}`, Enabled: false, TestStatus: "pending"}, + } + for i := range channels { + if err := db.Create(&channels[i]).Error; err != nil { + return err + } + } + fmt.Println("Generated notify channels:", len(channels)) + return nil +} + func generatePipelines(db *gorm.DB) error { pipelines := []models.Pipeline{ {Name: "前端构建", Code: "frontend-build", Type: "build", Source: "https://gitlab.com/ezops/frontend", Branch: "main", Enabled: true, Description: "前端项目构建流水线"}, diff --git a/frontend/src/components/cloud/CloudAccountManager.tsx b/frontend/src/components/cloud/CloudAccountManager.tsx index dbdefc2..abb472a 100644 --- a/frontend/src/components/cloud/CloudAccountManager.tsx +++ b/frontend/src/components/cloud/CloudAccountManager.tsx @@ -16,6 +16,7 @@ import { Plus, Search, Trash2, + Pencil, Key, Cloud, CheckCircle2, @@ -47,9 +48,10 @@ export function CloudAccountManager() { const [searchQuery, setSearchQuery] = useState(''); const [selectedProvider, setSelectedProvider] = useState('all'); - // 创建账号表单状态 + // 创建/编辑账号表单状态 const [formOpen, setFormOpen] = useState(false); const [submitting, setSubmitting] = useState(false); + const [editingAccount, setEditingAccount] = useState(null); const [formData, setFormData] = useState({ name: '', provider: 'aliyun', @@ -60,6 +62,7 @@ export function CloudAccountManager() { }); const resetForm = () => { + setEditingAccount(null); setFormData({ name: '', provider: 'aliyun', @@ -75,22 +78,40 @@ export function CloudAccountManager() { setFormOpen(true); }; - const handleCreate = async () => { + const handleOpenEdit = (account: any) => { + setEditingAccount(account); + setFormData({ + name: account.name || '', + provider: account.provider || 'aliyun', + access_key: '', + secret_key: '', + monthly_cost: String(account.monthly_cost ?? 0), + status: account.status || 'active', + }); + setFormOpen(true); + }; + + const handleSubmit = async () => { if (!formData.name.trim() || !formData.provider.trim()) return; try { setSubmitting(true); - await cloudApi.createAccount({ + const payload: any = { name: formData.name.trim(), provider: formData.provider, access_key: formData.access_key.trim(), secret_key: formData.secret_key.trim(), monthly_cost: Number(formData.monthly_cost) || 0, status: formData.status, - }); + }; + if (editingAccount) { + await cloudApi.updateAccount(editingAccount.id, payload); + } else { + await cloudApi.createAccount(payload); + } setFormOpen(false); loadData(); } catch (e) { - console.error('创建云账号失败:', e); + console.error(editingAccount ? '更新云账号失败:' : '创建云账号失败:', e); } finally { setSubmitting(false); } @@ -297,6 +318,14 @@ export function CloudAccountManager() {
+
- +
- + - {submitting ? : } - 创建账号 + {submitting ? : editingAccount ? : } + {editingAccount ? '保存修改' : '创建账号'} diff --git a/frontend/src/components/monitor/AlertManager.tsx b/frontend/src/components/monitor/AlertManager.tsx index 40ebc8d..4140485 100644 --- a/frontend/src/components/monitor/AlertManager.tsx +++ b/frontend/src/components/monitor/AlertManager.tsx @@ -94,6 +94,19 @@ export function AlertManager() { notify_channels: [] as string[], }); + // 通知渠道弹窗状态 + const [channelDialogOpen, setChannelDialogOpen] = useState(false); + const [notifyChannels, setNotifyChannels] = useState([]); + const [editingChannel, setEditingChannel] = useState(null); + const [channelSaving, setChannelSaving] = useState(false); + const [channelTesting, setChannelTesting] = useState(null); + const [channelForm, setChannelForm] = useState({ + name: '', + type: 'dingtalk', + config: '', + enabled: true, + }); + const resetRuleForm = () => { setRuleForm({ name: '', @@ -176,6 +189,105 @@ export function AlertManager() { })); }; + // ============ 通知渠道管理 ============ + const CHANNEL_TYPES: { key: string; label: string; placeholder: string }[] = [ + { key: 'dingtalk', label: '钉钉', placeholder: '{\n "webhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx",\n "secret": "SECxxx"\n}' }, + { key: 'wechat', label: '企业微信', placeholder: '{\n "webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"\n}' }, + { key: 'email', label: '邮件', placeholder: '{\n "to": "ops@ezops.io",\n "subject": "告警通知"\n}' }, + { key: 'sms', label: '短信', placeholder: '{\n "phones": "13800138000",\n "sign": "EZOps"\n}' }, + { key: 'webhook', label: 'Webhook', placeholder: '{\n "url": "https://example.com/alert"\n}' }, + ]; + + const loadNotifyChannels = async () => { + try { + const data = await alertApi.getNotifyChannels(); + setNotifyChannels((data as any)?.list || []); + } catch (error) { + console.error('Failed to load notify channels:', error); + } + }; + + const openChannelDialog = () => { + loadNotifyChannels(); + setEditingChannel(null); + setChannelForm({ name: '', type: 'dingtalk', config: '', enabled: true }); + setChannelDialogOpen(true); + }; + + const openEditChannel = (ch: any) => { + setEditingChannel(ch); + setChannelForm({ + name: ch.name || '', + type: ch.type || 'dingtalk', + config: ch.config || '', + enabled: ch.enabled !== false, + }); + }; + + const resetChannelForm = () => { + setEditingChannel(null); + setChannelForm({ name: '', type: 'dingtalk', config: '', enabled: 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, enabled: channelForm.enabled }); + } else { + await alertApi.createNotifyChannel(payload); + } + resetChannelForm(); + loadNotifyChannels(); + } catch (error) { + console.error('Failed to save notify channel:', error); + } finally { + setChannelSaving(false); + } + }; + + const handleDeleteChannel = async (id: any) => { + if (!window.confirm('确定删除此通知渠道?')) return; + try { + await alertApi.deleteNotifyChannel(id); + if (editingChannel && editingChannel.id === id) resetChannelForm(); + loadNotifyChannels(); + } catch (error) { + console.error('Failed to delete notify channel:', error); + } + }; + + const handleToggleChannel = async (ch: any) => { + try { + await alertApi.updateNotifyChannel(ch.id, { enabled: !ch.enabled }); + loadNotifyChannels(); + } catch (error) { + console.error('Failed to toggle notify channel:', error); + } + }; + + const handleTestChannel = async (id: any) => { + setChannelTesting(id); + try { + await alertApi.testNotifyChannel(id); + loadNotifyChannels(); + } catch (error) { + console.error('Failed to test notify channel:', error); + } finally { + setChannelTesting(null); + } + }; + + const getChannelTypeLabel = (type: string) => { + return CHANNEL_TYPES.find((t) => t.key === type)?.label || type; + }; + const loadData = async () => { try { setLoading(true); @@ -290,7 +402,7 @@ export function AlertManager() {

告警规则管理与告警事件处理

- @@ -795,6 +907,162 @@ export function AlertManager() { + + {/* 通知渠道管理弹窗 */} + { setChannelDialogOpen(open); if (!open) resetChannelForm(); }}> + + + 通知渠道配置 + + 管理告警通知渠道(钉钉/企业微信/邮件/短信/Webhook),配置后可在告警规则中引用 + + + +
+ {/* 渠道列表 */} +
+
+ + +
+ {notifyChannels.length === 0 ? ( +
+ 暂无通知渠道,请在右侧添加 +
+ ) : ( +
+ {notifyChannels.map((ch) => ( +
openEditChannel(ch)} + > +
+
+ {ch.name} + + {getChannelTypeLabel(ch.type)} + +
+ handleToggleChannel(ch)} + onClick={(e) => e.stopPropagation()} + className="data-[state=checked]:bg-emerald-600 scale-90" + /> +
+
+ {ch.test_status === 'success' ? ( + 测试通过 + ) : ( + 未测试 + )} +
e.stopPropagation()}> + + +
+
+
+ ))} +
+ )} +
+ + {/* 渠道表单 */} +
+ +
+ + setChannelForm({ ...channelForm, name: e.target.value })} + placeholder="如:运维告警群" + className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 h-9" + /> +
+
+ + +
+
+ +