feat: 云账号编辑功能 + 通知渠道种子数据 + 修复mock.go编译错误
- 新增 PUT /cloud-accounts/:id 后端端点(UpdateCloudAccount handler + 路由注册) - 前端 CloudAccountManager 添加编辑弹窗:复用创建表单,凭证留空保持不变 - 修复 scripts/mock.go 编译错误:补充缺失的 generateNotifyChannels 函数(5种通知渠道种子) - 包含前序未提交的告警通知渠道 CRUD 后端API + AlertManager 通知配置前端UI
This commit is contained in:
1 parent
01a9bebb0c
commit
af2e60acd9
9 files changed
+822
-51
No files matched your search
@@ -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.
|
// toJSON serializes a value to JSON string. Returns empty string on error.
|
||||||
func toJSON(v interface{}) string {
|
func toJSON(v interface{}) string {
|
||||||
b, err := json.Marshal(v)
|
b, err := json.Marshal(v)
|
||||||
|
|||||||
@@ -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 删除云账号
|
// DeleteCloudAccount 删除云账号
|
||||||
// @Summary 删除云账号
|
// @Summary 删除云账号
|
||||||
// @Description 删除指定云账号
|
// @Description 删除指定云账号
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
{
|
{
|
||||||
cloudAccounts.GET("", cloudAccountHandler.ListCloudAccounts)
|
cloudAccounts.GET("", cloudAccountHandler.ListCloudAccounts)
|
||||||
cloudAccounts.POST("", cloudAccountHandler.CreateCloudAccount)
|
cloudAccounts.POST("", cloudAccountHandler.CreateCloudAccount)
|
||||||
|
cloudAccounts.PUT("/:id", cloudAccountHandler.UpdateCloudAccount)
|
||||||
cloudAccounts.DELETE("/:id", cloudAccountHandler.DeleteCloudAccount)
|
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.DELETE("/rules/:id", alertHandler.DeleteRule)
|
||||||
alerts.POST("/rules/:id/toggle", alertHandler.ToggleRule)
|
alerts.POST("/rules/:id/toggle", alertHandler.ToggleRule)
|
||||||
alerts.POST("/:id/ack", alertHandler.AcknowledgeAlert)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流水线路由
|
// 流水线路由
|
||||||
|
|||||||
@@ -135,3 +135,18 @@ type AlertStats struct {
|
|||||||
type AcknowledgeAlertRequest struct {
|
type AcknowledgeAlertRequest struct {
|
||||||
Message string `json:"message"`
|
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"`
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ func generateMockData(db *gorm.DB) error {
|
|||||||
if err := generateAlertRules(db); err != nil {
|
if err := generateAlertRules(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := generateNotifyChannels(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := generatePipelines(db); err != nil {
|
if err := generatePipelines(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -368,6 +371,23 @@ func generateAlertRules(db *gorm.DB) error {
|
|||||||
return nil
|
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 {
|
func generatePipelines(db *gorm.DB) error {
|
||||||
pipelines := []models.Pipeline{
|
pipelines := []models.Pipeline{
|
||||||
{Name: "前端构建", Code: "frontend-build", Type: "build", Source: "https://gitlab.com/ezops/frontend", Branch: "main", Enabled: true, Description: "前端项目构建流水线"},
|
{Name: "前端构建", Code: "frontend-build", Type: "build", Source: "https://gitlab.com/ezops/frontend", Branch: "main", Enabled: true, Description: "前端项目构建流水线"},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Pencil,
|
||||||
Key,
|
Key,
|
||||||
Cloud,
|
Cloud,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -47,9 +48,10 @@ export function CloudAccountManager() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>('all');
|
const [selectedProvider, setSelectedProvider] = useState<string>('all');
|
||||||
|
|
||||||
// 创建账号表单状态
|
// 创建/编辑账号表单状态
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [editingAccount, setEditingAccount] = useState<any>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
provider: 'aliyun',
|
provider: 'aliyun',
|
||||||
@@ -60,6 +62,7 @@ export function CloudAccountManager() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
setEditingAccount(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
provider: 'aliyun',
|
provider: 'aliyun',
|
||||||
@@ -75,22 +78,40 @@ export function CloudAccountManager() {
|
|||||||
setFormOpen(true);
|
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;
|
if (!formData.name.trim() || !formData.provider.trim()) return;
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await cloudApi.createAccount({
|
const payload: any = {
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
provider: formData.provider,
|
provider: formData.provider,
|
||||||
access_key: formData.access_key.trim(),
|
access_key: formData.access_key.trim(),
|
||||||
secret_key: formData.secret_key.trim(),
|
secret_key: formData.secret_key.trim(),
|
||||||
monthly_cost: Number(formData.monthly_cost) || 0,
|
monthly_cost: Number(formData.monthly_cost) || 0,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
});
|
};
|
||||||
|
if (editingAccount) {
|
||||||
|
await cloudApi.updateAccount(editingAccount.id, payload);
|
||||||
|
} else {
|
||||||
|
await cloudApi.createAccount(payload);
|
||||||
|
}
|
||||||
setFormOpen(false);
|
setFormOpen(false);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('创建云账号失败:', e);
|
console.error(editingAccount ? '更新云账号失败:' : '创建云账号失败:', e);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -297,6 +318,14 @@ export function CloudAccountManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-blue-400"
|
||||||
|
onClick={() => handleOpenEdit(account)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -336,10 +365,10 @@ export function CloudAccountManager() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||||
<Cloud className="h-5 w-5 text-blue-400" />
|
<Cloud className="h-5 w-5 text-blue-400" />
|
||||||
添加云账号
|
{editingAccount ? '编辑云账号' : '添加云账号'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-slate-400">
|
<DialogDescription className="text-slate-400">
|
||||||
录入多云平台的访问凭证,用于资源同步与成本统计
|
{editingAccount ? '修改账号信息和访问凭证,留空则保持原凭证不变' : '录入多云平台的访问凭证,用于资源同步与成本统计'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -375,7 +404,10 @@ export function CloudAccountManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-slate-300">AccessKey</Label>
|
<Label className="text-slate-300">
|
||||||
|
AccessKey
|
||||||
|
{editingAccount && <span className="text-xs text-slate-500 ml-2">留空保持不变</span>}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="LTAI..."
|
placeholder="LTAI..."
|
||||||
value={formData.access_key}
|
value={formData.access_key}
|
||||||
@@ -385,7 +417,10 @@ export function CloudAccountManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-slate-300">SecretKey</Label>
|
<Label className="text-slate-300">
|
||||||
|
SecretKey
|
||||||
|
{editingAccount && <span className="text-xs text-slate-500 ml-2">留空保持不变</span>}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
@@ -429,10 +464,10 @@ export function CloudAccountManager() {
|
|||||||
<Button
|
<Button
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
disabled={!formData.name.trim() || submitting}
|
disabled={!formData.name.trim() || submitting}
|
||||||
onClick={handleCreate}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Plus className="h-4 w-4 mr-2" />}
|
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : editingAccount ? <Pencil className="h-4 w-4 mr-2" /> : <Plus className="h-4 w-4 mr-2" />}
|
||||||
创建账号
|
{editingAccount ? '保存修改' : '创建账号'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ export function AlertManager() {
|
|||||||
notify_channels: [] as string[],
|
notify_channels: [] as string[],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 通知渠道弹窗状态
|
||||||
|
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
|
||||||
|
const [notifyChannels, setNotifyChannels] = useState<any[]>([]);
|
||||||
|
const [editingChannel, setEditingChannel] = useState<any>(null);
|
||||||
|
const [channelSaving, setChannelSaving] = useState(false);
|
||||||
|
const [channelTesting, setChannelTesting] = useState<number | null>(null);
|
||||||
|
const [channelForm, setChannelForm] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 'dingtalk',
|
||||||
|
config: '',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
const resetRuleForm = () => {
|
const resetRuleForm = () => {
|
||||||
setRuleForm({
|
setRuleForm({
|
||||||
name: '',
|
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 () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -290,7 +402,7 @@ export function AlertManager() {
|
|||||||
<p className="text-sm text-slate-500 mt-1">告警规则管理与告警事件处理</p>
|
<p className="text-sm text-slate-500 mt-1">告警规则管理与告警事件处理</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={openChannelDialog}>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
通知配置
|
通知配置
|
||||||
</Button>
|
</Button>
|
||||||
@@ -795,6 +907,162 @@ export function AlertManager() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 通知渠道管理弹窗 */}
|
||||||
|
<Dialog open={channelDialogOpen} onOpenChange={(open) => { setChannelDialogOpen(open); if (!open) resetChannelForm(); }}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-700 text-slate-100 max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-slate-100">通知渠道配置</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500">
|
||||||
|
管理告警通知渠道(钉钉/企业微信/邮件/短信/Webhook),配置后可在告警规则中引用
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 max-h-[65vh] overflow-y-auto pr-1">
|
||||||
|
{/* 渠道列表 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-slate-300 text-sm">已配置渠道</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 border-slate-700 text-slate-300 hover:bg-slate-800 text-xs"
|
||||||
|
onClick={resetChannelForm}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
新建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{notifyChannels.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-sm text-slate-600 border border-dashed border-slate-800 rounded-lg">
|
||||||
|
暂无通知渠道,请在右侧添加
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{notifyChannels.map((ch) => (
|
||||||
|
<div
|
||||||
|
key={ch.id}
|
||||||
|
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
editingChannel && editingChannel.id === ch.id
|
||||||
|
? 'border-blue-500/50 bg-blue-500/5'
|
||||||
|
: 'border-slate-800 bg-slate-800/30 hover:border-slate-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => openEditChannel(ch)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm text-slate-200 truncate">{ch.name}</span>
|
||||||
|
<Badge variant="outline" className="border-slate-600 text-slate-400 text-[10px] py-0">
|
||||||
|
{getChannelTypeLabel(ch.type)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={ch.enabled !== false}
|
||||||
|
onCheckedChange={() => handleToggleChannel(ch)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="data-[state=checked]:bg-emerald-600 scale-90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
{ch.test_status === 'success' ? (
|
||||||
|
<span className="text-[11px] text-emerald-400">测试通过</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-600">未测试</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 text-xs"
|
||||||
|
disabled={channelTesting === ch.id}
|
||||||
|
onClick={() => handleTestChannel(ch.id)}
|
||||||
|
>
|
||||||
|
{channelTesting === ch.id ? <Loader2 className="h-3 w-3 animate-spin" /> : '测试'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-red-400/70 hover:text-red-400 hover:bg-red-500/10 text-xs"
|
||||||
|
onClick={() => handleDeleteChannel(ch.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 渠道表单 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="text-slate-300 text-sm">
|
||||||
|
{editingChannel ? '编辑渠道' : '新建渠道'}
|
||||||
|
</Label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-slate-400 text-xs">渠道名称 <span className="text-red-400">*</span></Label>
|
||||||
|
<Input
|
||||||
|
value={channelForm.name}
|
||||||
|
onChange={(e) => setChannelForm({ ...channelForm, name: e.target.value })}
|
||||||
|
placeholder="如:运维告警群"
|
||||||
|
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-slate-400 text-xs">渠道类型</Label>
|
||||||
|
<select
|
||||||
|
value={channelForm.type}
|
||||||
|
onChange={(e) => setChannelForm({ ...channelForm, type: e.target.value })}
|
||||||
|
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
|
||||||
|
>
|
||||||
|
{CHANNEL_TYPES.map(({ key, label }) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-slate-400 text-xs">配置(JSON)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={channelForm.config}
|
||||||
|
onChange={(e) => setChannelForm({ ...channelForm, config: e.target.value })}
|
||||||
|
placeholder={CHANNEL_TYPES.find((t) => t.key === channelForm.type)?.placeholder || ''}
|
||||||
|
rows={5}
|
||||||
|
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 font-mono text-xs resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{editingChannel && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={channelForm.enabled}
|
||||||
|
onCheckedChange={(v) => setChannelForm({ ...channelForm, enabled: v })}
|
||||||
|
className="data-[state=checked]:bg-emerald-600"
|
||||||
|
/>
|
||||||
|
<Label className="text-slate-400 text-xs">启用此渠道</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
onClick={() => setChannelDialogOpen(false)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
disabled={!channelForm.name.trim() || channelSaving}
|
||||||
|
onClick={handleSaveChannel}
|
||||||
|
>
|
||||||
|
{channelSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
{editingChannel ? '保存修改' : '添加渠道'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { alertCenterApi } from '@/services/api';
|
import { alertCenterApi, alertApi } from '@/services/api';
|
||||||
|
|
||||||
interface Alert {
|
interface Alert {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,11 +59,12 @@ interface AlertRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationChannel {
|
interface NotificationChannel {
|
||||||
id: string;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'email' | 'slack' | 'webhook' | 'sms' | 'pagerduty';
|
type: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
config: Record<string, string>;
|
config: string;
|
||||||
|
test_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AlertCenter: React.FC = () => {
|
const AlertCenter: React.FC = () => {
|
||||||
@@ -125,8 +126,9 @@ const AlertCenter: React.FC = () => {
|
|||||||
|
|
||||||
const fetchChannels = async () => {
|
const fetchChannels = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await alertCenterApi.getChannels();
|
const res = await alertApi.getNotifyChannels();
|
||||||
setChannels(res.data || []);
|
const list = (res as any)?.list || [];
|
||||||
|
setChannels(list);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('获取通知渠道失败');
|
toast.error('获取通知渠道失败');
|
||||||
}
|
}
|
||||||
@@ -173,6 +175,85 @@ const AlertCenter: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 通知渠道管理
|
||||||
|
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) => {
|
||||||
|
if (!window.confirm('确定删除此通知渠道?')) 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) => {
|
const getSeverityBadge = (severity: string) => {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'critical':
|
case 'critical':
|
||||||
@@ -489,43 +570,55 @@ const AlertCenter: React.FC = () => {
|
|||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => toast.info('添加渠道功能开发中')}>
|
<Button onClick={openCreateChannel}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
添加渠道
|
添加渠道
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
{channels.length === 0 ? (
|
||||||
{channels.map((channel) => (
|
<div className="text-center py-16 text-muted-foreground border border-dashed rounded-lg">
|
||||||
<Card key={channel.id}>
|
暂无通知渠道,点击「添加渠道」创建
|
||||||
<CardHeader className="pb-3">
|
</div>
|
||||||
<div className="flex items-start justify-between">
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{getChannelIcon(channel.type)}
|
{channels.map((channel) => (
|
||||||
<CardTitle className="text-base">{channel.name}</CardTitle>
|
<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>
|
</div>
|
||||||
<Badge variant={channel.enabled ? 'default' : 'secondary'}>
|
</CardHeader>
|
||||||
{channel.enabled ? '已启用' : '已禁用'}
|
<CardContent className="pt-0">
|
||||||
</Badge>
|
<div className="text-sm text-muted-foreground mb-3">
|
||||||
</div>
|
类型: {getChannelTypeLabel(channel.type)}
|
||||||
</CardHeader>
|
{channel.test_status === 'success' && (
|
||||||
<CardContent className="pt-0">
|
<span className="ml-2 text-green-500">测试通过</span>
|
||||||
<div className="text-sm text-muted-foreground capitalize mb-4">
|
)}
|
||||||
类型: {channel.type}
|
</div>
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleTestChannel(channel.id)}>
|
||||||
<Button variant="outline" size="sm" className="flex-1">
|
<Zap className="w-4 h-4 mr-1.5" />
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
测试
|
||||||
配置
|
</Button>
|
||||||
</Button>
|
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleToggleChannel(channel)}>
|
||||||
<Button variant="outline" size="sm" className="flex-1">
|
<Settings className="w-4 h-4 mr-1.5" />
|
||||||
<Zap className="w-4 h-4 mr-2" />
|
{channel.enabled ? '禁用' : '启用'}
|
||||||
测试
|
</Button>
|
||||||
</Button>
|
<Button variant="outline" size="sm" onClick={() => handleDeleteChannel(channel.id)}>
|
||||||
</div>
|
<X className="w-4 h-4" />
|
||||||
</CardContent>
|
</Button>
|
||||||
</Card>
|
</div>
|
||||||
))}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -624,6 +717,63 @@ const AlertCenter: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 通知渠道创建/编辑对话框 */}
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -148,6 +148,22 @@ export const alertApi = {
|
|||||||
acknowledge: async (id: number | string) => {
|
acknowledge: async (id: number | string) => {
|
||||||
return http.post(`/alerts/${id}/ack`);
|
return http.post(`/alerts/${id}/ack`);
|
||||||
},
|
},
|
||||||
|
// 通知渠道管理
|
||||||
|
getNotifyChannels: async () => {
|
||||||
|
return http.get('/alerts/notify-channels');
|
||||||
|
},
|
||||||
|
createNotifyChannel: async (data: any) => {
|
||||||
|
return http.post('/alerts/notify-channels', data);
|
||||||
|
},
|
||||||
|
updateNotifyChannel: async (id: number | string, data: any) => {
|
||||||
|
return http.put(`/alerts/notify-channels/${id}`, data);
|
||||||
|
},
|
||||||
|
deleteNotifyChannel: async (id: number | string) => {
|
||||||
|
return http.delete(`/alerts/notify-channels/${id}`);
|
||||||
|
},
|
||||||
|
testNotifyChannel: async (id: number | string) => {
|
||||||
|
return http.post(`/alerts/notify-channels/${id}/test`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== 流水线 API ====================
|
// ==================== 流水线 API ====================
|
||||||
@@ -311,6 +327,9 @@ export const cloudApi = {
|
|||||||
createAccount: async (data: any) => {
|
createAccount: async (data: any) => {
|
||||||
return http.post('/cloud-accounts', data);
|
return http.post('/cloud-accounts', data);
|
||||||
},
|
},
|
||||||
|
updateAccount: async (id: number | string, data: any) => {
|
||||||
|
return http.put(`/cloud-accounts/${id}`, data);
|
||||||
|
},
|
||||||
deleteAccount: async (id: number | string) => {
|
deleteAccount: async (id: number | string) => {
|
||||||
return http.delete(`/cloud-accounts/${id}`);
|
return http.delete(`/cloud-accounts/${id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user