mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(api): add alert system with CRUD endpoints and statistics
Introduce a comprehensive alert management system for monitoring system events and notifications. Changes include: - Add Alert model with type, severity, status, and metadata fields - Implement AlertHandler with full CRUD operations (create, list, get, acknowledge, resolve, dismiss) - Add alert statistics endpoint for counts by status and severity - Register Alert model in database auto-migration - Add minute-level aggregation to log stats (limited to 6-hour range)
This commit is contained in:
@@ -143,7 +143,7 @@ func main() {
|
|||||||
|
|
||||||
// Auto Migrate
|
// Auto Migrate
|
||||||
if logDB != db {
|
if logDB != db {
|
||||||
if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.ProviderGroup{}, &model.APIKey{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.SyncOutbox{}); err != nil {
|
if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.ProviderGroup{}, &model.APIKey{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.SyncOutbox{}, &model.Alert{}); err != nil {
|
||||||
fatal(logger, "failed to auto migrate", "err", err)
|
fatal(logger, "failed to auto migrate", "err", err)
|
||||||
}
|
}
|
||||||
if err := logDB.AutoMigrate(&model.LogRecord{}); err != nil {
|
if err := logDB.AutoMigrate(&model.LogRecord{}); err != nil {
|
||||||
@@ -153,7 +153,7 @@ func main() {
|
|||||||
fatal(logger, "failed to ensure log indexes", "err", err)
|
fatal(logger, "failed to ensure log indexes", "err", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.ProviderGroup{}, &model.APIKey{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.LogRecord{}, &model.SyncOutbox{}); err != nil {
|
if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.ProviderGroup{}, &model.APIKey{}, &model.Model{}, &model.Binding{}, &model.Namespace{}, &model.OperationLog{}, &model.LogRecord{}, &model.SyncOutbox{}, &model.Alert{}); err != nil {
|
||||||
fatal(logger, "failed to auto migrate", "err", err)
|
fatal(logger, "failed to auto migrate", "err", err)
|
||||||
}
|
}
|
||||||
if err := service.EnsureLogIndexes(db); err != nil {
|
if err := service.EnsureLogIndexes(db); err != nil {
|
||||||
@@ -220,6 +220,7 @@ func main() {
|
|||||||
adminHandler := api.NewAdminHandler(db, logDB, masterService, syncService, statsService, logPartitioner)
|
adminHandler := api.NewAdminHandler(db, logDB, masterService, syncService, statsService, logPartitioner)
|
||||||
masterHandler := api.NewMasterHandler(db, logDB, masterService, syncService, statsService, logPartitioner)
|
masterHandler := api.NewMasterHandler(db, logDB, masterService, syncService, statsService, logPartitioner)
|
||||||
dashboardHandler := api.NewDashboardHandler(db, logDB, statsService, logPartitioner)
|
dashboardHandler := api.NewDashboardHandler(db, logDB, statsService, logPartitioner)
|
||||||
|
alertHandler := api.NewAlertHandler(db)
|
||||||
internalHandler := api.NewInternalHandler(db)
|
internalHandler := api.NewInternalHandler(db)
|
||||||
featureHandler := api.NewFeatureHandler(rdb)
|
featureHandler := api.NewFeatureHandler(rdb)
|
||||||
authHandler := api.NewAuthHandler(db, rdb, adminService, masterService)
|
authHandler := api.NewAuthHandler(db, rdb, adminService, masterService)
|
||||||
@@ -358,6 +359,13 @@ func main() {
|
|||||||
adminGroup.GET("/realtime", adminHandler.GetAdminRealtime)
|
adminGroup.GET("/realtime", adminHandler.GetAdminRealtime)
|
||||||
adminGroup.GET("/dashboard/summary", dashboardHandler.GetSummary)
|
adminGroup.GET("/dashboard/summary", dashboardHandler.GetSummary)
|
||||||
adminGroup.GET("/apikey-stats/summary", adminHandler.GetAPIKeyStatsSummary)
|
adminGroup.GET("/apikey-stats/summary", adminHandler.GetAPIKeyStatsSummary)
|
||||||
|
adminGroup.GET("/alerts", alertHandler.ListAlerts)
|
||||||
|
adminGroup.POST("/alerts", alertHandler.CreateAlert)
|
||||||
|
adminGroup.GET("/alerts/stats", alertHandler.GetAlertStats)
|
||||||
|
adminGroup.GET("/alerts/:id", alertHandler.GetAlert)
|
||||||
|
adminGroup.POST("/alerts/:id/ack", alertHandler.AcknowledgeAlert)
|
||||||
|
adminGroup.POST("/alerts/:id/resolve", alertHandler.ResolveAlert)
|
||||||
|
adminGroup.DELETE("/alerts/:id", alertHandler.DismissAlert)
|
||||||
adminGroup.POST("/bindings", handler.CreateBinding)
|
adminGroup.POST("/bindings", handler.CreateBinding)
|
||||||
adminGroup.GET("/bindings", handler.ListBindings)
|
adminGroup.GET("/bindings", handler.ListBindings)
|
||||||
adminGroup.GET("/bindings/:id", handler.GetBinding)
|
adminGroup.GET("/bindings/:id", handler.GetBinding)
|
||||||
|
|||||||
405
internal/api/alert_handler.go
Normal file
405
internal/api/alert_handler.go
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ez-api/ez-api/internal/model"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertHandler handles alert-related API endpoints
|
||||||
|
type AlertHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlertHandler creates a new AlertHandler
|
||||||
|
func NewAlertHandler(db *gorm.DB) *AlertHandler {
|
||||||
|
return &AlertHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertView represents an alert in API responses
|
||||||
|
type AlertView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RelatedID uint `json:"related_id,omitempty"`
|
||||||
|
RelatedType string `json:"related_type,omitempty"`
|
||||||
|
RelatedName string `json:"related_name,omitempty"`
|
||||||
|
Metadata string `json:"metadata,omitempty"`
|
||||||
|
AckedAt *int64 `json:"acked_at,omitempty"`
|
||||||
|
AckedBy string `json:"acked_by,omitempty"`
|
||||||
|
ResolvedAt *int64 `json:"resolved_at,omitempty"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAlertView(a model.Alert) AlertView {
|
||||||
|
view := AlertView{
|
||||||
|
ID: a.ID,
|
||||||
|
Type: string(a.Type),
|
||||||
|
Severity: string(a.Severity),
|
||||||
|
Status: string(a.Status),
|
||||||
|
Title: a.Title,
|
||||||
|
Message: a.Message,
|
||||||
|
RelatedID: a.RelatedID,
|
||||||
|
RelatedType: a.RelatedType,
|
||||||
|
RelatedName: a.RelatedName,
|
||||||
|
Metadata: a.Metadata,
|
||||||
|
AckedBy: a.AckedBy,
|
||||||
|
CreatedAt: a.CreatedAt.UTC().Unix(),
|
||||||
|
UpdatedAt: a.UpdatedAt.UTC().Unix(),
|
||||||
|
}
|
||||||
|
if a.AckedAt != nil {
|
||||||
|
ts := a.AckedAt.UTC().Unix()
|
||||||
|
view.AckedAt = &ts
|
||||||
|
}
|
||||||
|
if a.ResolvedAt != nil {
|
||||||
|
ts := a.ResolvedAt.UTC().Unix()
|
||||||
|
view.ResolvedAt = &ts
|
||||||
|
}
|
||||||
|
if a.ExpiresAt != nil {
|
||||||
|
ts := a.ExpiresAt.UTC().Unix()
|
||||||
|
view.ExpiresAt = &ts
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAlertsResponse is the response for listing alerts
|
||||||
|
type ListAlertsResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Items []AlertView `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAlerts godoc
|
||||||
|
// @Summary List alerts
|
||||||
|
// @Description List system alerts with optional filters
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security AdminAuth
|
||||||
|
// @Param limit query int false "limit (default 50, max 200)"
|
||||||
|
// @Param offset query int false "offset"
|
||||||
|
// @Param status query string false "filter by status (active, acknowledged, resolved, dismissed)"
|
||||||
|
// @Param severity query string false "filter by severity (info, warning, critical)"
|
||||||
|
// @Param type query string false "filter by type (rate_limit, error_spike, quota_exceeded, key_disabled, key_expired, provider_down)"
|
||||||
|
// @Success 200 {object} ListAlertsResponse
|
||||||
|
// @Failure 500 {object} gin.H
|
||||||
|
// @Router /admin/alerts [get]
|
||||||
|
func (h *AlertHandler) ListAlerts(c *gin.Context) {
|
||||||
|
limit, offset := parseLimitOffset(c)
|
||||||
|
|
||||||
|
q := h.db.Model(&model.Alert{}).Order("id desc")
|
||||||
|
|
||||||
|
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if severity := strings.TrimSpace(c.Query("severity")); severity != "" {
|
||||||
|
q = q.Where("severity = ?", severity)
|
||||||
|
}
|
||||||
|
if alertType := strings.TrimSpace(c.Query("type")); alertType != "" {
|
||||||
|
q = q.Where("type = ?", alertType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count alerts", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var alerts []model.Alert
|
||||||
|
if err := q.Limit(limit).Offset(offset).Find(&alerts).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list alerts", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]AlertView, 0, len(alerts))
|
||||||
|
for _, a := range alerts {
|
||||||
|
items = append(items, toAlertView(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, ListAlertsResponse{
|
||||||
|
Total: total,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlert godoc
|
||||||
|
// @Summary Get alert
|
||||||
|
// @Description Get a single alert by ID
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security AdminAuth
|
||||||
|
// @Param id path int true "Alert ID"
|
||||||
|
// @Success 200 {object} AlertView
|
||||||
|
// @Failure 400 {object} gin.H
|
||||||
|
// @Failure 404 {object} gin.H
|
||||||
|
// @Router /admin/alerts/{id} [get]
|
||||||
|
func (h *AlertHandler) GetAlert(c *gin.Context) {
|
||||||
|
id, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var alert model.Alert
|
||||||
|
if err := h.db.First(&alert, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, toAlertView(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAlertRequest is the request body for creating an alert
|
||||||
|
type CreateAlertRequest struct {
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
Severity string `json:"severity" binding:"required"`
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RelatedID uint `json:"related_id"`
|
||||||
|
RelatedType string `json:"related_type"`
|
||||||
|
RelatedName string `json:"related_name"`
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAlert godoc
|
||||||
|
// @Summary Create alert
|
||||||
|
// @Description Create a new system alert
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security AdminAuth
|
||||||
|
// @Param request body CreateAlertRequest true "Alert data"
|
||||||
|
// @Success 201 {object} AlertView
|
||||||
|
// @Failure 400 {object} gin.H
|
||||||
|
// @Failure 500 {object} gin.H
|
||||||
|
// @Router /admin/alerts [post]
|
||||||
|
func (h *AlertHandler) CreateAlert(c *gin.Context) {
|
||||||
|
var req CreateAlertRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type
|
||||||
|
validTypes := map[string]bool{
|
||||||
|
"rate_limit": true, "error_spike": true, "quota_exceeded": true,
|
||||||
|
"key_disabled": true, "key_expired": true, "provider_down": true,
|
||||||
|
}
|
||||||
|
if !validTypes[req.Type] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert type"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate severity
|
||||||
|
validSeverities := map[string]bool{"info": true, "warning": true, "critical": true}
|
||||||
|
if !validSeverities[req.Severity] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid severity"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alert := model.Alert{
|
||||||
|
Type: model.AlertType(req.Type),
|
||||||
|
Severity: model.AlertSeverity(req.Severity),
|
||||||
|
Status: model.AlertStatusActive,
|
||||||
|
Title: strings.TrimSpace(req.Title),
|
||||||
|
Message: strings.TrimSpace(req.Message),
|
||||||
|
RelatedID: req.RelatedID,
|
||||||
|
RelatedType: strings.TrimSpace(req.RelatedType),
|
||||||
|
RelatedName: strings.TrimSpace(req.RelatedName),
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
|
||||||
|
t := time.Unix(*req.ExpiresAt, 0).UTC()
|
||||||
|
alert.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&alert).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create alert", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, toAlertView(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AckAlertRequest is the request body for acknowledging an alert
|
||||||
|
type AckAlertRequest struct {
|
||||||
|
AckedBy string `json:"acked_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcknowledgeAlert godoc
|
||||||
|
// @Summary Acknowledge alert
|
||||||
|
// @Description Mark an alert as acknowledged
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security AdminAuth
|
||||||
|
// @Param id path int true "Alert ID"
|
||||||
|
// @Param request body AckAlertRequest false "Ack data"
|
||||||
|
// @Success 200 {object} AlertView
|
||||||
|
// @Failure 400 {object} gin.H
|
||||||
|
// @Failure 404 {object} gin.H
|
||||||
|
// @Failure 500 {object} gin.H
|
||||||
|
// @Router /admin/alerts/{id}/ack [post]
|
||||||
|
func (h *AlertHandler) AcknowledgeAlert(c *gin.Context) {
|
||||||
|
id, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var alert model.Alert
|
||||||
|
if err := h.db.First(&alert, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req AckAlertRequest
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
update := map[string]any{
|
||||||
|
"status": model.AlertStatusAcknowledged,
|
||||||
|
"acked_at": now,
|
||||||
|
"acked_by": strings.TrimSpace(req.AckedBy),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Model(&alert).Updates(update).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to acknowledge alert", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.First(&alert, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload alert", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, toAlertView(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAlert godoc
|
||||||
|
// @Summary Resolve alert
|
||||||
|
// @Description Mark an alert as resolved
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security AdminAuth
|
||||||
|
// @Param id path int true "Alert ID"
|
||||||
|
// @Success 200 {object} AlertView
|
||||||
|
// @Failure 400 {object} gin.H
|
||||||
|
// @Failure 404 {object} gin.H
|
||||||
|
// @Failure 500 {object} gin.H
|
||||||
|
// @Router /admin/alerts/{id}/resolve [post]
|
||||||
|
func (h *AlertHandler) ResolveAlert(c *gin.Context) {
|
||||||
|
id, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var alert model.Alert
|
||||||
|
if err := h.db.First(&alert, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
update := map[string]any{
|
||||||
|
"status": model.AlertStatusResolved,
|
||||||
|
"resolved_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Model(&alert).Updates(update).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve alert", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.First(&alert, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload alert", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, toAlertView(alert))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissAlert godoc
|
||||||
|
// @Summary Dismiss alert
|
||||||
|
// @Description Dismiss an alert (soft delete)
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security AdminAuth
|
||||||
|
// @Param id path int true "Alert ID"
|
||||||
|
// @Success 200 {object} gin.H
|
||||||
|
// @Failure 400 {object} gin.H
|
||||||
|
// @Failure 404 {object} gin.H
|
||||||
|
// @Failure 500 {object} gin.H
|
||||||
|
// @Router /admin/alerts/{id} [delete]
|
||||||
|
func (h *AlertHandler) DismissAlert(c *gin.Context) {
|
||||||
|
id, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var alert model.Alert
|
||||||
|
if err := h.db.First(&alert, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Model(&alert).Update("status", model.AlertStatusDismissed).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to dismiss alert", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "dismissed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertStats represents alert statistics
|
||||||
|
type AlertStats struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Active int64 `json:"active"`
|
||||||
|
Acknowledged int64 `json:"acknowledged"`
|
||||||
|
Resolved int64 `json:"resolved"`
|
||||||
|
Critical int64 `json:"critical"`
|
||||||
|
Warning int64 `json:"warning"`
|
||||||
|
Info int64 `json:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlertStats godoc
|
||||||
|
// @Summary Alert statistics
|
||||||
|
// @Description Get alert count statistics by status and severity
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security AdminAuth
|
||||||
|
// @Success 200 {object} AlertStats
|
||||||
|
// @Failure 500 {object} gin.H
|
||||||
|
// @Router /admin/alerts/stats [get]
|
||||||
|
func (h *AlertHandler) GetAlertStats(c *gin.Context) {
|
||||||
|
var total, active, acknowledged, resolved, critical, warning, info int64
|
||||||
|
|
||||||
|
h.db.Model(&model.Alert{}).Count(&total)
|
||||||
|
h.db.Model(&model.Alert{}).Where("status = ?", "active").Count(&active)
|
||||||
|
h.db.Model(&model.Alert{}).Where("status = ?", "acknowledged").Count(&acknowledged)
|
||||||
|
h.db.Model(&model.Alert{}).Where("status = ?", "resolved").Count(&resolved)
|
||||||
|
h.db.Model(&model.Alert{}).Where("severity = ? AND status = ?", "critical", "active").Count(&critical)
|
||||||
|
h.db.Model(&model.Alert{}).Where("severity = ? AND status = ?", "warning", "active").Count(&warning)
|
||||||
|
h.db.Model(&model.Alert{}).Where("severity = ? AND status = ?", "info", "active").Count(&info)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, AlertStats{
|
||||||
|
Total: total,
|
||||||
|
Active: active,
|
||||||
|
Acknowledged: acknowledged,
|
||||||
|
Resolved: resolved,
|
||||||
|
Critical: critical,
|
||||||
|
Warning: warning,
|
||||||
|
Info: info,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -235,6 +235,8 @@ type GroupedStatsItem struct {
|
|||||||
Month string `json:"month,omitempty"`
|
Month string `json:"month,omitempty"`
|
||||||
// For group_by=hour
|
// For group_by=hour
|
||||||
Hour string `json:"hour,omitempty"`
|
Hour string `json:"hour,omitempty"`
|
||||||
|
// For group_by=minute
|
||||||
|
Minute string `json:"minute,omitempty"`
|
||||||
|
|
||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
TokensIn int64 `json:"tokens_in"`
|
TokensIn int64 `json:"tokens_in"`
|
||||||
@@ -348,24 +350,29 @@ func (h *Handler) deleteLogsBefore(cutoff time.Time, keyID uint, modelName strin
|
|||||||
|
|
||||||
// LogStats godoc
|
// LogStats godoc
|
||||||
// @Summary Log stats (admin)
|
// @Summary Log stats (admin)
|
||||||
// @Description Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month/hour). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse.
|
// @Description Aggregate log stats with basic filtering. Use group_by param for grouped statistics (model/day/month/hour/minute). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse. Note: minute-level aggregation is limited to 6-hour time ranges.
|
||||||
// @Tags admin
|
// @Tags admin
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security AdminAuth
|
// @Security AdminAuth
|
||||||
// @Param since query int false "unix seconds"
|
// @Param since query int false "unix seconds"
|
||||||
// @Param until query int false "unix seconds"
|
// @Param until query int false "unix seconds"
|
||||||
// @Param group_by query string false "group by dimension: model, day, month, hour. Returns GroupedStatsResponse when specified." Enums(model, day, month, hour)
|
// @Param group_by query string false "group by dimension: model, day, month, hour, minute. Returns GroupedStatsResponse when specified." Enums(model, day, month, hour, minute)
|
||||||
// @Success 200 {object} LogStatsResponse "Default aggregated stats (when group_by is not specified)"
|
// @Success 200 {object} LogStatsResponse "Default aggregated stats (when group_by is not specified)"
|
||||||
// @Success 200 {object} GroupedStatsResponse "Grouped stats (when group_by is specified)"
|
// @Success 200 {object} GroupedStatsResponse "Grouped stats (when group_by is specified)"
|
||||||
|
// @Failure 400 {object} gin.H
|
||||||
// @Failure 500 {object} gin.H
|
// @Failure 500 {object} gin.H
|
||||||
// @Router /admin/logs/stats [get]
|
// @Router /admin/logs/stats [get]
|
||||||
func (h *Handler) LogStats(c *gin.Context) {
|
func (h *Handler) LogStats(c *gin.Context) {
|
||||||
q := h.logBaseQuery()
|
q := h.logBaseQuery()
|
||||||
|
|
||||||
|
var sinceTime, untilTime *time.Time
|
||||||
if t, ok := parseUnixSeconds(c.Query("since")); ok {
|
if t, ok := parseUnixSeconds(c.Query("since")); ok {
|
||||||
q = q.Where("created_at >= ?", t)
|
q = q.Where("created_at >= ?", t)
|
||||||
|
sinceTime = &t
|
||||||
}
|
}
|
||||||
if t, ok := parseUnixSeconds(c.Query("until")); ok {
|
if t, ok := parseUnixSeconds(c.Query("until")); ok {
|
||||||
q = q.Where("created_at <= ?", t)
|
q = q.Where("created_at <= ?", t)
|
||||||
|
untilTime = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
groupBy := strings.TrimSpace(c.Query("group_by"))
|
groupBy := strings.TrimSpace(c.Query("group_by"))
|
||||||
@@ -382,6 +389,9 @@ func (h *Handler) LogStats(c *gin.Context) {
|
|||||||
case "hour":
|
case "hour":
|
||||||
h.logStatsByHour(c, q)
|
h.logStatsByHour(c, q)
|
||||||
return
|
return
|
||||||
|
case "minute":
|
||||||
|
h.logStatsByMinute(c, q, sinceTime, untilTime)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: aggregated stats (backward compatible)
|
// Default: aggregated stats (backward compatible)
|
||||||
@@ -544,6 +554,61 @@ func (h *Handler) logStatsByHour(c *gin.Context, q *gorm.DB) {
|
|||||||
c.JSON(http.StatusOK, GroupedStatsResponse{Items: items})
|
c.JSON(http.StatusOK, GroupedStatsResponse{Items: items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxMinuteRangeDuration is the maximum time range allowed for minute-level aggregation (6 hours)
|
||||||
|
const maxMinuteRangeDuration = 6 * time.Hour
|
||||||
|
|
||||||
|
// logStatsByMinute handles group_by=minute with time range validation
|
||||||
|
func (h *Handler) logStatsByMinute(c *gin.Context, q *gorm.DB, sinceTime, untilTime *time.Time) {
|
||||||
|
// Validate time range - minute-level aggregation requires since/until and max 6 hours
|
||||||
|
if sinceTime == nil || untilTime == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "minute-level aggregation requires both 'since' and 'until' parameters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := untilTime.Sub(*sinceTime)
|
||||||
|
if duration > maxMinuteRangeDuration {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "time range too large for minute-level aggregation",
|
||||||
|
"max_hours": 6,
|
||||||
|
"actual_hours": duration.Hours(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration < 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "'until' must be after 'since'"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type minuteStats struct {
|
||||||
|
Minute string
|
||||||
|
Cnt int64
|
||||||
|
TokensIn int64
|
||||||
|
TokensOut int64
|
||||||
|
AvgLatencyMs float64
|
||||||
|
}
|
||||||
|
var rows []minuteStats
|
||||||
|
// PostgreSQL DATE_TRUNC for minute-level aggregation
|
||||||
|
if err := q.Select(`DATE_TRUNC('minute', created_at) as minute, COUNT(*) as cnt, COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out, COALESCE(AVG(latency_ms),0) as avg_latency_ms`).
|
||||||
|
Group("minute").
|
||||||
|
Order("minute ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate by minute", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]GroupedStatsItem, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
items = append(items, GroupedStatsItem{
|
||||||
|
Minute: r.Minute,
|
||||||
|
Count: r.Cnt,
|
||||||
|
TokensIn: r.TokensIn,
|
||||||
|
TokensOut: r.TokensOut,
|
||||||
|
AvgLatencyMs: r.AvgLatencyMs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, GroupedStatsResponse{Items: items})
|
||||||
|
}
|
||||||
|
|
||||||
// ListSelfLogs godoc
|
// ListSelfLogs godoc
|
||||||
// @Summary List logs (master)
|
// @Summary List logs (master)
|
||||||
// @Description List request logs for the authenticated master
|
// @Description List request logs for the authenticated master
|
||||||
|
|||||||
56
internal/model/alert.go
Normal file
56
internal/model/alert.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertType defines the type of alert
|
||||||
|
type AlertType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertTypeRateLimit AlertType = "rate_limit"
|
||||||
|
AlertTypeErrorSpike AlertType = "error_spike"
|
||||||
|
AlertTypeQuotaExceeded AlertType = "quota_exceeded"
|
||||||
|
AlertTypeKeyDisabled AlertType = "key_disabled"
|
||||||
|
AlertTypeKeyExpired AlertType = "key_expired"
|
||||||
|
AlertTypeProviderDown AlertType = "provider_down"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertSeverity defines the severity level of an alert
|
||||||
|
type AlertSeverity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertSeverityInfo AlertSeverity = "info"
|
||||||
|
AlertSeverityWarning AlertSeverity = "warning"
|
||||||
|
AlertSeverityCritical AlertSeverity = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertStatus defines the status of an alert
|
||||||
|
type AlertStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertStatusActive AlertStatus = "active"
|
||||||
|
AlertStatusAcknowledged AlertStatus = "acknowledged"
|
||||||
|
AlertStatusResolved AlertStatus = "resolved"
|
||||||
|
AlertStatusDismissed AlertStatus = "dismissed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alert represents a system alert or notification
|
||||||
|
type Alert struct {
|
||||||
|
gorm.Model
|
||||||
|
Type AlertType `gorm:"size:50;not null;index" json:"type"`
|
||||||
|
Severity AlertSeverity `gorm:"size:20;not null;index" json:"severity"`
|
||||||
|
Status AlertStatus `gorm:"size:20;not null;default:'active';index" json:"status"`
|
||||||
|
Title string `gorm:"size:255;not null" json:"title"`
|
||||||
|
Message string `gorm:"type:text" json:"message"`
|
||||||
|
RelatedID uint `gorm:"index" json:"related_id,omitempty"`
|
||||||
|
RelatedType string `gorm:"size:50" json:"related_type,omitempty"` // master, key, apikey, provider_group
|
||||||
|
RelatedName string `gorm:"size:255" json:"related_name,omitempty"`
|
||||||
|
Metadata string `gorm:"type:text" json:"metadata,omitempty"` // JSON encoded additional data
|
||||||
|
AckedAt *time.Time `json:"acked_at,omitempty"`
|
||||||
|
AckedBy string `gorm:"size:100" json:"acked_by,omitempty"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` // Auto-dismiss after this time
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user