Files
LokiStack/backend/controllers/alerts.go
T
logan 2d73661dd1 feat: complete Loki query, insights, settings and deployment workflows
Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application.

Validated with go test ./..., npm run build, and npm run lint.
2026-10-25 21:28:32 +08:00

284 lines
7.9 KiB
Go

package controllers
import (
"strconv"
"time"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/repository"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
// AlertsController 告警规则 + 飞书通知配置(参照 opsx-sec 通知 API 设计)
type AlertsController struct{}
func NewAlertsController() *AlertsController { return &AlertsController{} }
// ==================== 通知配置 ====================
// GetNotifyConfig 通知配置(只回 enabled/hasWebhook/hasSecret)
func (ctrl *AlertsController) GetNotifyConfig(c *gin.Context) {
cfg := services.GetNotifyConfig()
utils.Success(c, services.NotifyPublicConfig{
Enabled: cfg.Enabled, HasWebhook: cfg.Webhook != "", HasSecret: cfg.SigningSecret != "",
})
}
// UpdateNotifyConfig 保存(webhook/签名留空=保留旧值)
func (ctrl *AlertsController) UpdateNotifyConfig(c *gin.Context) {
var req dto.NotifyConfigRequest
if !utils.BindAndValidate(c, &req) {
return
}
if err := services.SaveNotifyConfig(req.Enabled, req.Webhook, req.SigningSecret); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "saved"})
}
// TestNotifyConfig 发送测试卡片(验证 webhook 连通)
func (ctrl *AlertsController) TestNotifyConfig(c *gin.Context) {
cfg := services.GetNotifyConfig()
if !cfg.Enabled || cfg.Webhook == "" {
utils.Error(c, 400, "请先保存并启用飞书通知")
return
}
if err := services.SendLarkCard("✅ opsx-loki 通知测试", "green", []string{
"**时间**: " + time.Now().Format("2006-01-02 15:04:05"),
"**说明**: 管理员在设置页手动触发的连通性测试。",
}); err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, gin.H{"status": "sent"})
}
// ==================== 告警规则 CRUD ====================
func (ctrl *AlertsController) ListRules(c *gin.Context) {
db := repository.GetDB()
db.RLock()
out := make([]map[string]interface{}, 0, len(db.AlertRules))
for i := range db.AlertRules {
out = append(out, db.AlertRules[i].Sanitized())
}
db.RUnlock()
utils.Success(c, out)
}
func (ctrl *AlertsController) CreateRule(c *gin.Context) {
var req dto.AlertRuleRequest
if !utils.BindAndValidate(c, &req) {
return
}
if req.WindowMin <= 0 {
req.WindowMin = 15
}
if req.CooldownMin <= 0 {
req.CooldownMin = 60
}
if req.Threshold < 0 {
req.Threshold = 0
}
db := repository.GetDB()
rule := &models.AlertRule{
ID: db.NextID("alertRules"), Name: req.Name, SourceID: req.SourceID,
ChannelIDs: req.ChannelIDs,
Query: req.Query, WindowMin: req.WindowMin, Threshold: req.Threshold,
Enabled: req.Enabled, CooldownMin: req.CooldownMin,
}
db.Lock()
db.AlertRules = append(db.AlertRules, *rule)
db.RequestSave()
db.Unlock()
utils.Success(c, rule.Sanitized())
}
func (ctrl *AlertsController) UpdateRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var req map[string]interface{}
if !utils.BindAndValidate(c, &req) {
return
}
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.AlertRules {
if db.AlertRules[i].ID != uint(id) {
continue
}
if v, ok := req["name"].(string); ok && v != "" {
db.AlertRules[i].Name = v
}
if v, ok := req["query"].(string); ok && v != "" {
db.AlertRules[i].Query = v
}
if v, ok := req["sourceId"].(float64); ok && v > 0 {
db.AlertRules[i].SourceID = uint(v)
}
if arr, ok := req["channelIds"].([]interface{}); ok {
ids := make([]uint, 0, len(arr))
for _, item := range arr {
if v, ok := item.(float64); ok && v > 0 {
ids = append(ids, uint(v))
}
}
db.AlertRules[i].ChannelIDs = ids
}
if v, ok := req["windowMin"].(float64); ok && v > 0 {
db.AlertRules[i].WindowMin = int(v)
}
if v, ok := req["threshold"].(float64); ok && v >= 0 {
db.AlertRules[i].Threshold = int(v)
}
if v, ok := req["cooldownMin"].(float64); ok && v > 0 {
db.AlertRules[i].CooldownMin = int(v)
}
if v, ok := req["enabled"].(bool); ok {
db.AlertRules[i].Enabled = v
}
db.AlertRules[i].UpdatedAt = time.Now()
db.RequestSave()
utils.Success(c, db.AlertRules[i].Sanitized())
return
}
utils.NotFound(c, "规则不存在")
}
func (ctrl *AlertsController) DeleteRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.AlertRules {
if db.AlertRules[i].ID == uint(id) {
db.AlertRules = append(db.AlertRules[:i], db.AlertRules[i+1:]...)
db.RequestSave()
utils.Success(c, gin.H{"message": "deleted"})
return
}
}
utils.NotFound(c, "规则不存在")
}
// CheckNow 手动触发一次全量检查(不等定时器)
func (ctrl *AlertsController) CheckNow(c *gin.Context) {
services.RunAlertCheck(c.Request.Context())
utils.Success(c, gin.H{"message": "checked"})
}
// Deliveries 发送历史(最近 50)
func (ctrl *AlertsController) Deliveries(c *gin.Context) {
db := repository.GetDB()
db.RLock()
out := make([]map[string]interface{}, 0, len(db.AlertDeliveries))
for i := len(db.AlertDeliveries) - 1; i >= 0 && len(out) < 50; i-- {
d := db.AlertDeliveries[i]
out = append(out, map[string]interface{}{
"id": d.ID, "ruleId": d.RuleID, "ruleName": d.RuleName,
"count": d.Count, "status": d.Status, "reason": d.Reason, "createdAt": d.CreatedAt,
})
}
db.RUnlock()
utils.Success(c, out)
}
// ==================== 通知通道(多 webhook) ====================
func (ctrl *AlertsController) ListChannels(c *gin.Context) {
utils.Success(c, services.ListNotifyChannels())
}
func (ctrl *AlertsController) SaveChannel(c *gin.Context) {
var req dto.NotifyChannelRequest
if !utils.BindAndValidate(c, &req) {
return
}
// PUT /channels/:id 时路径参数优先
if pathID := c.Param("id"); pathID != "" {
if v, err := strconv.ParseUint(pathID, 10, 64); err == nil {
req.ID = uint(v)
}
}
ch, err := services.SaveNotifyChannel(req.ID, req.Name, req.Webhook, req.SigningSecret, req.Enabled)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, ch.Sanitized())
}
func (ctrl *AlertsController) DeleteChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := services.DeleteNotifyChannel(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deleted"})
}
// TestChannel 向指定通道发送测试卡片(用表单参数或已存通道)
func (ctrl *AlertsController) TestChannel(c *gin.Context) {
var req dto.NotifyChannelRequest
if !utils.BindAndValidate(c, &req) {
return
}
// 测试已存通道时 name 可不传(沿用原名称);直测新 webhook 时缺省名
name := req.Name
if req.ID > 0 {
if existing := services.GetNotifyChannel(req.ID); existing != nil && name == "" {
name = existing.Name
}
} else if name == "" {
name = "临时测试通道"
}
var channelID uint
if req.ID > 0 {
channelID = req.ID
// 表单 webhook 非空时先保存再测(未保存也可测新地址)
if req.Webhook != "" {
if _, err := services.SaveNotifyChannel(req.ID, name, req.Webhook, req.SigningSecret, true); err != nil {
utils.Error(c, 400, err.Error())
return
}
}
} else if req.Webhook != "" {
// 未保存直测:保存后可删
ch, err := services.SaveNotifyChannel(0, name, req.Webhook, req.SigningSecret, true)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
channelID = ch.ID
}
if channelID == 0 {
utils.BadRequest(c, "请提供通道 ID 或 webhook 地址")
return
}
if err := services.SendLarkCardToChannel(channelID, "✅ opsx-loki 通道测试", "green", []string{
"**时间**: " + time.Now().Format("2006-01-02 15:04:05"),
"**说明**: 通道连通性测试。",
}); err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, gin.H{"status": "sent"})
}