diff --git a/Sense/server/app/admin/router/sense_ops_alert.go b/Sense/server/app/admin/router/sense_ops_alert.go new file mode 100644 index 0000000..0ba8d32 --- /dev/null +++ b/Sense/server/app/admin/router/sense_ops_alert.go @@ -0,0 +1,22 @@ +package router + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/ops_alert" + "git.ilapage.cn/ila/yovision/Sense/server/common/actions" + "git.ilapage.cn/ila/yovision/Sense/server/common/middleware" +) + +func init() { routerCheckRole = append(routerCheckRole, registerSenseOpsAlertRouter) } + +func registerSenseOpsAlertRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) { + api := &ops_alert.API{} + r := v1.Group("/ops-alerts").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction()) + r.GET("", api.List) + r.GET("/:id", api.Get) + r.POST("/evaluate", api.Evaluate) + r.POST("/:id/acknowledge", api.Acknowledge) + r.POST("/:id/recover", api.Recover) +} diff --git a/Sense/server/app/admin/router/sense_ops_alert_test.go b/Sense/server/app/admin/router/sense_ops_alert_test.go new file mode 100644 index 0000000..98466c7 --- /dev/null +++ b/Sense/server/app/admin/router/sense_ops_alert_test.go @@ -0,0 +1,32 @@ +package router + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "net/http" + "testing" +) + +func TestSenseOpsAlertRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + registerSenseOpsAlertRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{}) + wanted := map[string]bool{ + http.MethodGet + " /api/v1/ops-alerts": false, + http.MethodGet + " /api/v1/ops-alerts/:id": false, + http.MethodPost + " /api/v1/ops-alerts/evaluate": false, + http.MethodPost + " /api/v1/ops-alerts/:id/acknowledge": false, + http.MethodPost + " /api/v1/ops-alerts/:id/recover": false, + } + for _, route := range engine.Routes() { + key := route.Method + " " + route.Path + if _, ok := wanted[key]; ok { + wanted[key] = true + } + } + for route, found := range wanted { + if !found { + t.Fatalf("route not registered: %s", route) + } + } +} diff --git a/Sense/server/app/sense/ops_alert/apis.go b/Sense/server/app/sense/ops_alert/apis.go new file mode 100644 index 0000000..1a6e28d --- /dev/null +++ b/Sense/server/app/sense/ops_alert/apis.go @@ -0,0 +1,121 @@ +package ops_alert + +import ( + "errors" + "git.ilapage.cn/ila/yovision/Sense/server/common" + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-admin-team/go-admin-core/sdk/api" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" + coreService "github.com/go-admin-team/go-admin-core/sdk/service" + "net/http" + "time" +) + +const auditSuccess, auditFailure = "1", "2" + +type API struct{ api.Api } + +func (e *API) service(c *gin.Context) (*Service, error) { + base := coreService.Service{} + if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil { + return nil, err + } + return NewService(base.Orm), nil +} +func (e *API) List(c *gin.Context) { + service, err := e.service(c) + if err != nil { + e.writeError(err) + return + } + request := PageRequest{} + if err = e.MakeContext(c).Bind(&request).Errors; err != nil { + e.audit(c, service, "List", auditFailure, "告警查询条件格式不正确") + e.Error(http.StatusBadRequest, err, "查询条件格式不正确") + return + } + response, err := service.List(request) + if err != nil { + e.audit(c, service, "List", auditFailure, "告警列表查询失败") + e.writeError(err) + return + } + e.audit(c, service, "List", auditSuccess, "读取运维告警列表") + e.OK(response, "查询成功") +} +func (e *API) Get(c *gin.Context) { + service, err := e.service(c) + if err != nil { + e.writeError(err) + return + } + response, err := service.Get(c.Param("id")) + if err != nil { + e.audit(c, service, "Get", auditFailure, "告警详情查询失败") + e.writeError(err) + return + } + e.audit(c, service, "Get", auditSuccess, "读取运维告警详情 "+response.Alert.ID) + e.OK(response, "查询成功") +} +func (e *API) Evaluate(c *gin.Context) { + service, err := e.service(c) + if err != nil { + e.writeError(err) + return + } + response, err := service.EvaluateSources(c.Request.Context()) + if err != nil { + e.audit(c, service, "Evaluate", auditFailure, "健康事实刷新失败") + e.writeError(err) + return + } + e.audit(c, service, "Evaluate", auditSuccess, "刷新 Sense 内部健康事实") + e.OK(response, "状态已刷新") +} +func (e *API) Acknowledge(c *gin.Context) { e.action(c, "Acknowledge") } +func (e *API) Recover(c *gin.Context) { e.action(c, "Recover") } +func (e *API) action(c *gin.Context, action string) { + service, err := e.service(c) + if err != nil { + e.writeError(err) + return + } + request := ActionRequest{} + if err = e.MakeContext(c).Bind(&request, binding.JSON).Errors; err != nil { + e.audit(c, service, action, auditFailure, "告警操作请求格式不正确") + e.Error(http.StatusBadRequest, err, "请求格式不正确") + return + } + var item Alert + if action == "Acknowledge" { + item, err = service.Acknowledge(c.Request.Context(), c.Param("id"), request, user.GetUserId(c)) + } else { + item, err = service.Recover(c.Request.Context(), c.Param("id"), request, user.GetUserId(c)) + } + if err != nil { + e.audit(c, service, action, auditFailure, "运维告警状态操作被拒绝") + e.writeError(err) + return + } + e.audit(c, service, action, auditSuccess, action+" "+item.ID) + e.OK(item, "操作成功") +} +func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) { + if err := WriteAudit(service.DB, Audit{Action: action, Method: c.Request.Method, Status: status, Username: user.GetUserName(c), UserID: user.GetUserId(c), ClientIP: common.GetClientIP(c), Route: c.FullPath(), Remark: remark, At: time.Now()}); err != nil { + api.GetRequestLogger(c).Errorf("ops alert audit failed: %s", err.Error()) + } +} +func (e *API) writeError(err error) { + switch { + case errors.Is(err, ErrInvalidFilter), errors.Is(err, ErrInvalidReason): + e.Error(http.StatusBadRequest, err, err.Error()) + case errors.Is(err, ErrVersionConflict), errors.Is(err, ErrInvalidTransition), errors.Is(err, ErrRecoveryWindowOpen): + e.Error(http.StatusConflict, err, err.Error()) + case errors.Is(err, ErrAlertNotFound): + e.Error(http.StatusNotFound, err, err.Error()) + default: + e.Error(http.StatusInternalServerError, err, "运维告警操作失败") + } +} diff --git a/Sense/server/app/sense/ops_alert/audit.go b/Sense/server/app/sense/ops_alert/audit.go new file mode 100644 index 0000000..532dbb6 --- /dev/null +++ b/Sense/server/app/sense/ops_alert/audit.go @@ -0,0 +1,19 @@ +package ops_alert + +import ( + adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models" + "gorm.io/gorm" + "time" +) + +type Audit struct { + Action, Method, Status, Username, ClientIP, Route, Remark string + UserID int + At time.Time +} + +func WriteAudit(db *gorm.DB, input Audit) error { + model := adminModels.SysOperaLog{Title: "运维告警", BusinessType: "other", Method: "ops_alert.API." + input.Action, RequestMethod: input.Method, OperatorType: "1", OperName: input.Username, OperUrl: input.Route, OperIp: input.ClientIP, Status: input.Status, OperTime: input.At.UTC(), Remark: input.Remark, CreatedAt: input.At.UTC(), UpdatedAt: input.At.UTC()} + model.CreateBy, model.UpdateBy = input.UserID, input.UserID + return db.Create(&model).Error +} diff --git a/Sense/server/app/sense/ops_alert/dto.go b/Sense/server/app/sense/ops_alert/dto.go new file mode 100644 index 0000000..682c861 --- /dev/null +++ b/Sense/server/app/sense/ops_alert/dto.go @@ -0,0 +1,37 @@ +package ops_alert + +import commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto" + +type PageRequest struct { + commonDto.Pagination `search:"-"` + AlertType string `form:"alertType"` + State string `form:"state"` + Keyword string `form:"keyword"` +} + +type Summary struct { + Active int64 `json:"active"` + Unacknowledged int64 `json:"unacknowledged"` + Recovering int64 `json:"recovering"` +} + +type PageResponse struct { + Summary Summary `json:"summary"` + List []Alert `json:"list"` + Count int64 `json:"count"` +} + +type DetailResponse struct { + Alert Alert `json:"alert"` + Transitions []Transition `json:"transitions"` +} + +type ActionRequest struct { + ExpectedVersion int64 `json:"expectedVersion" binding:"required,min=1"` + Reason string `json:"reason" binding:"required"` +} + +type EvaluateResponse struct { + Observed int `json:"observed"` + Abnormal int `json:"abnormal"` +} diff --git a/Sense/server/app/sense/ops_alert/models.go b/Sense/server/app/sense/ops_alert/models.go new file mode 100644 index 0000000..dfdb501 --- /dev/null +++ b/Sense/server/app/sense/ops_alert/models.go @@ -0,0 +1,62 @@ +package ops_alert + +import "time" + +const ( + StateUnacknowledged = "unacknowledged" + StateAcknowledged = "acknowledged" + StateRecovering = "recovering" + StateRecovered = "recovered" + + TypeDeviceOffline = "device_offline" + TypeAuthentication = "authentication_failed" + TypeClockDrift = "clock_drift" + TypeReconciliation = "reconciliation_failed" + TypeMediaShard = "media_shard_failed" + TypeControlTunnel = "control_tunnel_failed" +) + +type Alert struct { + ID string `gorm:"size:36;primaryKey" json:"id"` + Fingerprint string `gorm:"size:255;not null;uniqueIndex" json:"fingerprint"` + AlertType string `gorm:"size:32;not null;index" json:"alertType"` + Severity string `gorm:"size:16;not null;index" json:"severity"` + ObjectType string `gorm:"size:32;not null;index" json:"objectType"` + ObjectID string `gorm:"size:96;not null;index" json:"objectId"` + ObjectName string `gorm:"size:128;not null" json:"objectName"` + Location string `gorm:"size:255;not null;default:''" json:"location"` + State string `gorm:"size:24;not null;index" json:"state"` + Title string `gorm:"size:160;not null" json:"title"` + Detail string `gorm:"size:512;not null" json:"detail"` + NextAction string `gorm:"size:255;not null" json:"nextAction"` + LastSourceVersion string `gorm:"size:128;not null;default:''" json:"-"` + OccurrenceCount int `gorm:"not null;default:1" json:"occurrenceCount"` + Cycle int `gorm:"not null;default:1" json:"cycle"` + FirstSeenAt time.Time `gorm:"not null;index" json:"firstSeenAt"` + LastSeenAt time.Time `gorm:"not null;index" json:"lastSeenAt"` + HealthySince *time.Time `json:"healthySince,omitempty"` + AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"` + AcknowledgedBy int `gorm:"not null;default:0" json:"acknowledgedBy,omitempty"` + RecoveredAt *time.Time `json:"recoveredAt,omitempty"` + RecoveredBy int `gorm:"not null;default:0" json:"recoveredBy,omitempty"` + OperationalOnly bool `gorm:"not null;default:true" json:"operationalOnly"` + Version int64 `gorm:"not null;default:1" json:"version"` + CreatedAt time.Time `gorm:"not null" json:"createdAt"` + UpdatedAt time.Time `gorm:"not null" json:"updatedAt"` +} + +func (Alert) TableName() string { return "sense_ops_alerts" } + +type Transition struct { + ID string `gorm:"size:36;primaryKey" json:"id"` + AlertID string `gorm:"size:36;not null;index" json:"alertId"` + Cycle int `gorm:"not null" json:"cycle"` + Action string `gorm:"size:32;not null;index" json:"action"` + FromState string `gorm:"size:24;not null;default:''" json:"fromState"` + ToState string `gorm:"size:24;not null" json:"toState"` + Reason string `gorm:"size:512;not null;default:''" json:"reason"` + ActorUserID int `gorm:"not null;default:0" json:"actorUserId"` + CreatedAt time.Time `gorm:"not null;index" json:"createdAt"` +} + +func (Transition) TableName() string { return "sense_ops_alert_transitions" } diff --git a/Sense/server/app/sense/ops_alert/service.go b/Sense/server/app/sense/ops_alert/service.go new file mode 100644 index 0000000..15c6b70 --- /dev/null +++ b/Sense/server/app/sense/ops_alert/service.go @@ -0,0 +1,326 @@ +package ops_alert + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/edge_node" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/media" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard" +) + +var ( + ErrAlertNotFound = errors.New("运维告警不存在") + ErrInvalidFilter = errors.New("告警筛选条件不正确") + ErrInvalidReason = errors.New("处理说明需为 6 至 256 个字符") + ErrInvalidTransition = errors.New("当前告警状态不允许此操作") + ErrVersionConflict = errors.New("告警状态已变化,请刷新后重试") + ErrRecoveryWindowOpen = errors.New("健康恢复观察窗口尚未结束") +) + +const defaultRecoveryWindow = 5 * time.Minute + +type Service struct { + DB *gorm.DB + Now func() time.Time + RecoveryWindow time.Duration +} + +func NewService(db *gorm.DB) *Service { + return &Service{DB: db, Now: time.Now, RecoveryWindow: defaultRecoveryWindow} +} + +func (s *Service) List(request PageRequest) (PageResponse, error) { + if !validOptional(request.AlertType, alertTypes()) || !validOptional(request.State, states()) { + return PageResponse{}, ErrInvalidFilter + } + query := s.DB.Model(&Alert{}) + if request.AlertType != "" { + query = query.Where("alert_type = ?", request.AlertType) + } + if request.State != "" { + query = query.Where("state = ?", request.State) + } + if keyword := strings.TrimSpace(request.Keyword); keyword != "" { + like := "%" + keyword + "%" + query = query.Where("object_name LIKE ? OR location LIKE ? OR title LIKE ? OR detail LIKE ?", like, like, like, like) + } + response := PageResponse{} + if err := query.Count(&response.Count).Error; err != nil { + return response, err + } + if err := query.Order("CASE WHEN state = 'unacknowledged' THEN 0 WHEN state = 'acknowledged' THEN 1 WHEN state = 'recovering' THEN 2 ELSE 3 END, last_seen_at DESC").Offset((request.GetPageIndex() - 1) * request.GetPageSize()).Limit(request.GetPageSize()).Find(&response.List).Error; err != nil { + return response, err + } + active := []string{StateUnacknowledged, StateAcknowledged, StateRecovering} + if err := s.DB.Model(&Alert{}).Where("state IN ?", active).Count(&response.Summary.Active).Error; err != nil { + return response, err + } + if err := s.DB.Model(&Alert{}).Where("state = ?", StateUnacknowledged).Count(&response.Summary.Unacknowledged).Error; err != nil { + return response, err + } + if err := s.DB.Model(&Alert{}).Where("state = ?", StateRecovering).Count(&response.Summary.Recovering).Error; err != nil { + return response, err + } + return response, nil +} + +func (s *Service) Get(id string) (DetailResponse, error) { + response := DetailResponse{Transitions: []Transition{}} + if err := s.DB.First(&response.Alert, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return response, ErrAlertNotFound + } else if err != nil { + return response, err + } + if err := s.DB.Where("alert_id = ?", id).Order("created_at DESC").Find(&response.Transitions).Error; err != nil { + return response, err + } + return response, nil +} + +func (s *Service) Acknowledge(ctx context.Context, id string, request ActionRequest, actor int) (Alert, error) { + return s.transition(ctx, id, request, actor, StateUnacknowledged, StateAcknowledged, "acknowledge") +} + +func (s *Service) Recover(ctx context.Context, id string, request ActionRequest, actor int) (Alert, error) { + return s.transition(ctx, id, request, actor, StateRecovering, StateRecovered, "recover") +} + +func (s *Service) transition(ctx context.Context, id string, request ActionRequest, actor int, expectedState, targetState, action string) (Alert, error) { + reason := strings.TrimSpace(request.Reason) + if utf8.RuneCountInString(reason) < 6 || utf8.RuneCountInString(reason) > 256 { + return Alert{}, ErrInvalidReason + } + if request.ExpectedVersion < 1 { + return Alert{}, ErrVersionConflict + } + now := s.Now().UTC() + result := Alert{} + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current Alert + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return ErrAlertNotFound + } else if err != nil { + return err + } + if current.Version != request.ExpectedVersion { + return ErrVersionConflict + } + if current.State != expectedState { + return ErrInvalidTransition + } + if action == "recover" && (current.HealthySince == nil || now.Sub(current.HealthySince.UTC()) < s.RecoveryWindow) { + return ErrRecoveryWindowOpen + } + updates := map[string]any{"state": targetState, "version": current.Version + 1, "updated_at": now} + if action == "acknowledge" { + updates["acknowledged_at"], updates["acknowledged_by"] = now, actor + } else { + updates["recovered_at"], updates["recovered_by"] = now, actor + } + write := tx.Model(&Alert{}).Where("id = ? AND version = ? AND state = ?", id, current.Version, expectedState).Updates(updates) + if write.Error != nil { + return write.Error + } + if write.RowsAffected != 1 { + return ErrVersionConflict + } + if err := tx.Create(&Transition{ID: uuid.NewString(), AlertID: id, Cycle: current.Cycle, Action: action, FromState: expectedState, ToState: targetState, Reason: reason, ActorUserID: actor, CreatedAt: now}).Error; err != nil { + return err + } + return tx.First(&result, "id = ?", id).Error + }) + return result, err +} + +type observation struct { + Fingerprint, AlertType, Severity, ObjectType, ObjectID, ObjectName, Location, Title, Detail, NextAction, SourceVersion string + Abnormal bool +} + +func (s *Service) EvaluateSources(ctx context.Context) (EvaluateResponse, error) { + items, err := s.sourceObservations(ctx) + if err != nil { + return EvaluateResponse{}, err + } + response := EvaluateResponse{Observed: len(items)} + for _, item := range items { + if item.Abnormal { + response.Abnormal++ + } + if err = s.observe(ctx, item); err != nil { + return response, err + } + } + return response, nil +} + +func (s *Service) observe(ctx context.Context, item observation) error { + now := s.Now().UTC() + return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current Alert + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("fingerprint = ?", item.Fingerprint).First(¤t).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + if !item.Abnormal { + return nil + } + current = Alert{ID: uuid.NewString(), Fingerprint: item.Fingerprint, AlertType: item.AlertType, Severity: item.Severity, ObjectType: item.ObjectType, ObjectID: item.ObjectID, ObjectName: item.ObjectName, Location: item.Location, State: StateUnacknowledged, Title: item.Title, Detail: item.Detail, NextAction: item.NextAction, LastSourceVersion: item.SourceVersion, OccurrenceCount: 1, Cycle: 1, FirstSeenAt: now, LastSeenAt: now, OperationalOnly: true, Version: 1, CreatedAt: now, UpdatedAt: now} + if err = tx.Create(¤t).Error; err != nil { + return err + } + return tx.Create(&Transition{ID: uuid.NewString(), AlertID: current.ID, Cycle: 1, Action: "detected", ToState: StateUnacknowledged, Reason: "健康事实首次满足告警规则", CreatedAt: now}).Error + } + if err != nil { + return err + } + if item.Abnormal { + updates := map[string]any{"severity": item.Severity, "object_name": item.ObjectName, "location": item.Location, "title": item.Title, "detail": item.Detail, "next_action": item.NextAction, "last_seen_at": now, "updated_at": now, "healthy_since": nil} + if item.SourceVersion != current.LastSourceVersion { + updates["last_source_version"], updates["occurrence_count"] = item.SourceVersion, current.OccurrenceCount+1 + } + if current.State == StateRecovered { + updates["state"], updates["cycle"], updates["first_seen_at"], updates["occurrence_count"], updates["acknowledged_at"], updates["acknowledged_by"], updates["recovered_at"], updates["recovered_by"] = StateUnacknowledged, current.Cycle+1, now, 1, nil, 0, nil, 0 + if err = tx.Create(&Transition{ID: uuid.NewString(), AlertID: current.ID, Cycle: current.Cycle + 1, Action: "reopened", FromState: StateRecovered, ToState: StateUnacknowledged, Reason: "健康事实再次异常", CreatedAt: now}).Error; err != nil { + return err + } + } else if current.State == StateRecovering { + target := StateUnacknowledged + if current.AcknowledgedAt != nil { + target = StateAcknowledged + } + updates["state"] = target + if err = tx.Create(&Transition{ID: uuid.NewString(), AlertID: current.ID, Cycle: current.Cycle, Action: "relapsed", FromState: StateRecovering, ToState: target, Reason: "恢复观察期间再次异常", CreatedAt: now}).Error; err != nil { + return err + } + } + updates["version"] = current.Version + 1 + return tx.Model(&Alert{}).Where("id = ? AND version = ?", current.ID, current.Version).Updates(updates).Error + } + if current.State == StateUnacknowledged || current.State == StateAcknowledged { + if err = tx.Create(&Transition{ID: uuid.NewString(), AlertID: current.ID, Cycle: current.Cycle, Action: "health_restored", FromState: current.State, ToState: StateRecovering, Reason: "健康事实已恢复,进入稳定观察窗口", CreatedAt: now}).Error; err != nil { + return err + } + return tx.Model(&Alert{}).Where("id = ? AND version = ?", current.ID, current.Version).Updates(map[string]any{"state": StateRecovering, "healthy_since": now, "version": current.Version + 1, "updated_at": now}).Error + } + return nil + }) +} + +type admissionProjection struct { + DeviceID, Status, Detail string + CheckedAt time.Time +} + +func (admissionProjection) TableName() string { return "sense_admission_results" } + +func (s *Service) sourceObservations(ctx context.Context) ([]observation, error) { + now := s.Now().UTC() + result := []observation{} + var devices []deviceModels.Device + if err := s.DB.WithContext(ctx).Find(&devices).Error; err != nil { + return nil, err + } + var admissions []admissionProjection + if err := s.DB.WithContext(ctx).Find(&admissions).Error; err != nil && !isMissingTable(err) { + return nil, err + } + byDevice := map[string]admissionProjection{} + for _, item := range admissions { + byDevice[item.DeviceID] = item + } + for _, device := range devices { + if device.Status == deviceModels.StatusDisabled { + continue + } + state, detail, observed := device.AdapterStatus, "尚未获得设备接入结果", device.UpdatedAt + if item, ok := byDevice[device.ID]; ok { + state, detail, observed = item.Status, item.Detail, item.CheckedAt + } + text := strings.ToLower(state + " " + detail) + version := fmt.Sprintf("%d:%d", device.Version, observed.UnixNano()) + base := observation{ObjectType: "device", ObjectID: device.ID, ObjectName: device.Name, Location: device.Location, SourceVersion: version} + result = append(result, + withRule(base, TypeDeviceOffline, "high", "设备离线", detail, "检查设备供电、网络和地址后重新发现", containsAny(text, "offline", "unreachable", "timeout", "离线", "无法连接", "超时")), + withRule(base, TypeAuthentication, "high", "设备认证失败", detail, "更新设备凭据后重新执行接入验证", containsAny(text, "authentication_failed", "unauthorized", "auth", "认证", "凭据")), + withRule(base, TypeClockDrift, "medium", "设备时间漂移", detail, "校准设备时间后重新执行接入验证", containsAny(text, "clock_skew", "clock_drift", "time drift", "时间漂移", "时钟")), + ) + } + var routes []media.Route + if err := s.DB.WithContext(ctx).Find(&routes).Error; err != nil && !isMissingTable(err) { + return nil, err + } + for _, route := range routes { + converged := (route.Desired == media.DesiredRunning && (route.Actual == "ready" || route.Actual == "waiting")) || (route.Desired == media.DesiredStopped && route.Actual == "stopped") + base := observation{ObjectType: "media_route", ObjectID: route.ID, ObjectName: route.Path, SourceVersion: fmt.Sprintf("%d", route.Version)} + result = append(result, withRule(base, TypeReconciliation, "medium", "媒体状态未收敛", route.Detail, "在运维中心核对期望态并执行受控重试", !converged)) + } + var shards []media_shard.Shard + if err := s.DB.WithContext(ctx).Find(&shards).Error; err != nil && !isMissingTable(err) { + return nil, err + } + for _, shard := range shards { + if shard.Status == media_shard.StatusDisabled { + continue + } + stale := shard.LastProbeAt == nil || now.Sub(shard.LastProbeAt.UTC()) > 30*time.Second + base := observation{ObjectType: "media_shard", ObjectID: shard.ID, ObjectName: shard.Name, SourceVersion: fmt.Sprintf("%d:%d", shard.ConfigVersion, timeValue(shard.LastProbeAt))} + result = append(result, withRule(base, TypeMediaShard, "high", "媒体分片异常", shard.Detail, "检查 MediaMTX 进程和 Control API 后重新探测", shard.Status == media_shard.StatusFailed || stale)) + } + var nodes []edge_node.Node + if err := s.DB.WithContext(ctx).Find(&nodes).Error; err != nil && !isMissingTable(err) { + return nil, err + } + for _, node := range nodes { + base := observation{ObjectType: "edge_node", ObjectID: node.ID, ObjectName: node.Name, Location: node.Location, SourceVersion: fmt.Sprintf("%d:%d", node.ProjectionVersion, node.LastHeartbeatAt.UnixNano())} + result = append(result, + withRule(base, TypeDeviceOffline, "high", "边缘节点离线", "心跳超过 90 秒未更新", "检查节点进程、网络和机器身份后等待心跳恢复", now.Sub(node.LastHeartbeatAt.UTC()) > edge_node.HeartbeatTimeout), + withRule(base, TypeControlTunnel, "high", "控制隧道异常", node.ControlTunnelDetail, "检查节点到 Sense 的控制通道,不影响业务预警数据", node.ControlTunnelStatus != edge_node.ChannelReady), + ) + } + return result, nil +} + +func withRule(base observation, typ, severity, title, detail, next string, abnormal bool) observation { + base.Fingerprint = typ + ":" + base.ObjectType + ":" + base.ObjectID + base.AlertType = typ + base.Severity = severity + base.Title = title + base.Detail = detail + base.NextAction = next + base.Abnormal = abnormal + return base +} +func containsAny(value string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} +func isMissingTable(err error) bool { + text := strings.ToLower(err.Error()) + return strings.Contains(text, "no such table") || strings.Contains(text, "does not exist") +} +func timeValue(value *time.Time) int64 { + if value == nil { + return 0 + } + return value.UnixNano() +} +func validOptional(value string, allowed map[string]bool) bool { return value == "" || allowed[value] } +func states() map[string]bool { + return map[string]bool{StateUnacknowledged: true, StateAcknowledged: true, StateRecovering: true, StateRecovered: true} +} +func alertTypes() map[string]bool { + return map[string]bool{TypeDeviceOffline: true, TypeAuthentication: true, TypeClockDrift: true, TypeReconciliation: true, TypeMediaShard: true, TypeControlTunnel: true} +} diff --git a/Sense/server/app/sense/ops_alert/service_test.go b/Sense/server/app/sense/ops_alert/service_test.go new file mode 100644 index 0000000..1a8b804 --- /dev/null +++ b/Sense/server/app/sense/ops_alert/service_test.go @@ -0,0 +1,198 @@ +package ops_alert + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models" + deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/edge_node" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/media" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard" +) + +func opsAlertTestService(t *testing.T) (*Service, *gorm.DB, time.Time) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&Alert{}, &Transition{}, &deviceModels.Device{}, &admissionProjection{}, &media.Route{}, &media_shard.Shard{}, &edge_node.Node{}, &adminModels.SysOperaLog{}); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC) + service := NewService(db) + service.Now = func() time.Time { return now } + return service, db, now +} + +func TestEvaluateSourcesGeneratesSixTypesAndDeduplicates(t *testing.T) { + service, db, now := opsAlertTestService(t) + devices := []deviceModels.Device{ + {ID: "offline", Name: "东门摄像机", Location: "东门", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "verification_failed", Version: 1}, + {ID: "auth", Name: "仓库摄像机", Location: "仓库", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "verification_failed", Version: 2}, + {ID: "clock", Name: "南门摄像机", Location: "南门", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "verification_failed", Version: 3}, + } + if err := db.Create(&devices).Error; err != nil { + t.Fatal(err) + } + results := []admissionProjection{ + {DeviceID: "offline", Status: "device_offline", Detail: "设备离线,无法连接", CheckedAt: now}, + {DeviceID: "auth", Status: "authentication_failed", Detail: "设备认证失败", CheckedAt: now}, + {DeviceID: "clock", Status: "clock_skew", Detail: "设备时间漂移", CheckedAt: now}, + } + if err := db.Create(&results).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&media.Route{ID: "route-1", DeviceID: "auth", ProfileToken: "main", Path: "sense_auth", Desired: "running", Actual: "apply_failed", Detail: "配置未收敛", Version: 4, UpdatedAt: now}).Error; err != nil { + t.Fatal(err) + } + probe := now + if err := db.Create(&media_shard.Shard{ID: "shard-1", Name: "媒体服务 A", Mode: "local", ControlAPI: "http://127.0.0.1:9997", Capacity: 16, Status: media_shard.StatusFailed, Detail: "Control API 不可用", LastProbeAt: &probe, ConfigVersion: 1, CreatedAt: now, UpdatedAt: now}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&edge_node.Node{ID: "node-1", Name: "主楼边缘节点", Location: "机房", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now.Add(-10 * time.Second), LastCollectedAt: now.Add(-10 * time.Second), ControlTunnelStatus: edge_node.ChannelUnavailable, ControlTunnelDetail: "TLS 隧道断开", VideoPlaneStatus: edge_node.ChannelReady, ProjectionVersion: 7}).Error; err != nil { + t.Fatal(err) + } + + response, err := service.EvaluateSources(context.Background()) + if err != nil { + t.Fatal(err) + } + if response.Abnormal != 6 { + t.Fatalf("abnormal=%d want 6", response.Abnormal) + } + var alerts []Alert + if err = db.Find(&alerts).Error; err != nil { + t.Fatal(err) + } + if len(alerts) != 6 { + t.Fatalf("alerts=%d want 6", len(alerts)) + } + types := map[string]bool{} + for _, item := range alerts { + types[item.AlertType] = true + if !item.OperationalOnly { + t.Fatal("alert crossed operational boundary") + } + } + for _, typ := range []string{TypeDeviceOffline, TypeAuthentication, TypeClockDrift, TypeReconciliation, TypeMediaShard, TypeControlTunnel} { + if !types[typ] { + t.Fatalf("missing type %s", typ) + } + } + if _, err = service.EvaluateSources(context.Background()); err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&Alert{}).Count(&count) + if count != 6 { + t.Fatalf("duplicate active alerts: %d", count) + } + var offline Alert + if err = db.First(&offline, "fingerprint = ?", TypeDeviceOffline+":device:offline").Error; err != nil { + t.Fatal(err) + } + if offline.OccurrenceCount != 1 { + t.Fatalf("unchanged source counted twice: %d", offline.OccurrenceCount) + } +} + +func TestAcknowledgeRecoverAndReopenPreserveOneAlert(t *testing.T) { + service, db, now := opsAlertTestService(t) + device := deviceModels.Device{ID: "auth", Name: "仓库摄像机", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "verification_failed", Version: 1} + if err := db.Create(&device).Error; err != nil { + t.Fatal(err) + } + result := admissionProjection{DeviceID: "auth", Status: "authentication_failed", Detail: "认证失败", CheckedAt: now} + if err := db.Create(&result).Error; err != nil { + t.Fatal(err) + } + if _, err := service.EvaluateSources(context.Background()); err != nil { + t.Fatal(err) + } + var alert Alert + if err := db.First(&alert, "alert_type = ?", TypeAuthentication).Error; err != nil { + t.Fatal(err) + } + ack, err := service.Acknowledge(context.Background(), alert.ID, ActionRequest{ExpectedVersion: alert.Version, Reason: "已安排现场人员更新设备凭据"}, 7) + if err != nil { + t.Fatal(err) + } + if ack.State != StateAcknowledged || ack.AcknowledgedBy != 7 { + t.Fatalf("unexpected acknowledgement: %+v", ack) + } + if _, err = service.Acknowledge(context.Background(), alert.ID, ActionRequest{ExpectedVersion: alert.Version, Reason: "并发重复确认不应成功"}, 8); !errors.Is(err, ErrVersionConflict) { + t.Fatalf("want version conflict, got %v", err) + } + if err = db.Model(&admissionProjection{}).Where("device_id = ?", "auth").Updates(map[string]any{"status": "ready", "detail": "接入验证完成", "checked_at": now.Add(time.Minute)}).Error; err != nil { + t.Fatal(err) + } + service.Now = func() time.Time { return now.Add(time.Minute) } + if _, err = service.EvaluateSources(context.Background()); err != nil { + t.Fatal(err) + } + if err = db.First(&alert, "id = ?", alert.ID).Error; err != nil { + t.Fatal(err) + } + if alert.State != StateRecovering || alert.HealthySince == nil { + t.Fatalf("want recovering: %+v", alert) + } + if _, err = service.Recover(context.Background(), alert.ID, ActionRequest{ExpectedVersion: alert.Version, Reason: "恢复窗口尚未结束,不能关闭"}, 7); !errors.Is(err, ErrRecoveryWindowOpen) { + t.Fatalf("want recovery window error, got %v", err) + } + service.Now = func() time.Time { return now.Add(7 * time.Minute) } + recovered, err := service.Recover(context.Background(), alert.ID, ActionRequest{ExpectedVersion: alert.Version, Reason: "设备已稳定在线超过恢复观察窗口"}, 7) + if err != nil { + t.Fatal(err) + } + if recovered.State != StateRecovered { + t.Fatalf("state=%s", recovered.State) + } + if _, err = service.EvaluateSources(context.Background()); err != nil { + t.Fatal(err) + } + if err = db.Model(&admissionProjection{}).Where("device_id = ?", "auth").Updates(map[string]any{"status": "authentication_failed", "detail": "认证再次失败", "checked_at": now.Add(8 * time.Minute)}).Error; err != nil { + t.Fatal(err) + } + service.Now = func() time.Time { return now.Add(8 * time.Minute) } + if _, err = service.EvaluateSources(context.Background()); err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&Alert{}).Where("fingerprint = ?", recovered.Fingerprint).Count(&count) + if count != 1 { + t.Fatalf("reopen duplicated alert: %d", count) + } + if err = db.First(&alert, "id = ?", alert.ID).Error; err != nil { + t.Fatal(err) + } + if alert.State != StateUnacknowledged || alert.Cycle != 2 { + t.Fatalf("unexpected reopened alert: %+v", alert) + } + var transitions int64 + db.Model(&Transition{}).Where("alert_id = ?", alert.ID).Count(&transitions) + if transitions < 5 { + t.Fatalf("transitions=%d", transitions) + } +} + +func TestAuditIsStoredOnlyInGoAdminOperationLog(t *testing.T) { + _, db, now := opsAlertTestService(t) + if err := WriteAudit(db, Audit{Action: "Acknowledge", Method: "POST", Status: "1", Username: "operator", UserID: 9, ClientIP: "127.0.0.1", Route: "/api/v1/ops-alerts/:id/acknowledge", Remark: "确认运维告警", At: now}); err != nil { + t.Fatal(err) + } + var log adminModels.SysOperaLog + if err := db.First(&log).Error; err != nil { + t.Fatal(err) + } + if log.Title != "运维告警" || log.CreateBy != 9 { + t.Fatalf("unexpected audit: %+v", log) + } +} diff --git a/Sense/server/cmd/migrate/migration/version/2026082816000_ops_alert.go b/Sense/server/cmd/migrate/migration/version/2026082816000_ops_alert.go new file mode 100644 index 0000000..295a14b --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026082816000_ops_alert.go @@ -0,0 +1,74 @@ +package version + +import ( + "fmt" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/ops_alert" + "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration" + migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models" + common "git.ilapage.cn/ila/yovision/Sense/server/common/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "runtime" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseOpsAlert) +} + +func migrateSenseOpsAlert(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&ops_alert.Alert{}, &ops_alert.Transition{}); err != nil { + return err + } + root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense", MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1"}) + if err != nil { + return err + } + page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlert", Title: "运维告警", Icon: "warning", Path: "ops-alert", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:ops-alert:list", ParentId: root.MenuId, Component: "/sense/ops-alert/index", Sort: 11, Visible: "0", IsFrame: "1"}) + if err != nil { + return err + } + detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlertDetail", Title: "查看告警详情", MenuType: "F", Action: "GET", Permission: "sense:ops-alert:detail", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1"}) + if err != nil { + return err + } + refresh, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlertEvaluate", Title: "刷新告警状态", MenuType: "F", Action: "POST", Permission: "sense:ops-alert:evaluate", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 2, Visible: "1", IsFrame: "1"}) + if err != nil { + return err + } + ack, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlertAcknowledge", Title: "确认运维告警", MenuType: "F", Action: "POST", Permission: "sense:ops-alert:acknowledge", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 3, Visible: "1", IsFrame: "1"}) + if err != nil { + return err + } + recover, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOpsAlertRecover", Title: "确认告警恢复", MenuType: "F", Action: "POST", Permission: "sense:ops-alert:recover", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 4, Visible: "1", IsFrame: "1"}) + if err != nil { + return err + } + readPolicies := [][2]string{{"/api/v1/ops-alerts", "GET"}, {"/api/v1/ops-alerts/:id", "GET"}} + for _, role := range []string{"implementation_operator", "site_admin", "viewer"} { + if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, detail}); err != nil { + return err + } + for _, policy := range readPolicies { + if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil { + return err + } + } + } + for _, role := range []string{"implementation_operator", "site_admin"} { + if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{refresh, ack, recover}); err != nil { + return err + } + for _, policy := range [][2]string{{"/api/v1/ops-alerts/evaluate", "POST"}, {"/api/v1/ops-alerts/:id/acknowledge", "POST"}, {"/api/v1/ops-alerts/:id/recover", "POST"}} { + if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil { + return err + } + } + } + if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/Sense/server/cmd/migrate/migration/version/2026082816000_ops_alert_test.go b/Sense/server/cmd/migrate/migration/version/2026082816000_ops_alert_test.go new file mode 100644 index 0000000..299f66a --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026082816000_ops_alert_test.go @@ -0,0 +1,39 @@ +package version + +import ( + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/ops_alert" + migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models" + common "git.ilapage.cn/ila/yovision/Sense/server/common/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "testing" +) + +func TestSenseOpsAlertMigrationRBAC(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil { + t.Fatal(err) + } + for _, role := range []string{"implementation_operator", "site_admin", "viewer"} { + if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil { + t.Fatal(err) + } + } + const version = "2026082816000_ops_alert.go" + if err = migrateSenseOpsAlert(db, version); err != nil { + t.Fatal(err) + } + var pages, viewerWrites, operatorWrites int64 + db.Model(&migrationModels.SysMenu{}).Where("permission = ?", "sense:ops-alert:list").Count(&pages) + db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v1 LIKE ? AND v2 = ?", "viewer", "/api/v1/ops-alerts%", "POST").Count(&viewerWrites) + db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v1 LIKE ? AND v2 = ?", "implementation_operator", "/api/v1/ops-alerts%", "POST").Count(&operatorWrites) + if pages != 1 || viewerWrites != 0 || operatorWrites != 3 { + t.Fatalf("unexpected RBAC page=%d viewerWrites=%d operatorWrites=%d", pages, viewerWrites, operatorWrites) + } + if !db.Migrator().HasTable(&ops_alert.Alert{}) || !db.Migrator().HasTable(&ops_alert.Transition{}) { + t.Fatal("ops alert tables missing") + } +} diff --git a/Sense/ui/src/api/sense/ops-alert.js b/Sense/ui/src/api/sense/ops-alert.js new file mode 100644 index 0000000..c2da278 --- /dev/null +++ b/Sense/ui/src/api/sense/ops-alert.js @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +export function listOpsAlerts(query) { + return request({ url: '/api/v1/ops-alerts', method: 'get', params: query }) +} + +export function getOpsAlert(id) { + return request({ url: `/api/v1/ops-alerts/${encodeURIComponent(id)}`, method: 'get' }) +} + +export function evaluateOpsAlerts() { + return request({ url: '/api/v1/ops-alerts/evaluate', method: 'post' }) +} + +export function acknowledgeOpsAlert(id, data) { + return request({ url: `/api/v1/ops-alerts/${encodeURIComponent(id)}/acknowledge`, method: 'post', data }) +} + +export function recoverOpsAlert(id, data) { + return request({ url: `/api/v1/ops-alerts/${encodeURIComponent(id)}/recover`, method: 'post', data }) +} diff --git a/Sense/ui/src/views/sense/ops-alert/index.vue b/Sense/ui/src/views/sense/ops-alert/index.vue new file mode 100644 index 0000000..2163323 --- /dev/null +++ b/Sense/ui/src/views/sense/ops-alert/index.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/Sense/ui/src/views/sense/ops-alert/opsAlertState.js b/Sense/ui/src/views/sense/ops-alert/opsAlertState.js new file mode 100644 index 0000000..d55837c --- /dev/null +++ b/Sense/ui/src/views/sense/ops-alert/opsAlertState.js @@ -0,0 +1,30 @@ +const typeLabels = { + device_offline: '设备离线', + authentication_failed: '认证失败', + clock_drift: '时间漂移', + reconciliation_failed: '状态对账失败', + media_shard_failed: '媒体分片异常', + control_tunnel_failed: '控制隧道异常' +} + +const stateLabels = { + unacknowledged: '待确认', + acknowledged: '已确认', + recovering: '恢复观察', + recovered: '已恢复' +} + +export function alertTypeLabel(value) { return typeLabels[value] || value || '未知类型' } +export function alertStateLabel(value) { return stateLabels[value] || value || '未知状态' } +export function alertStateType(value) { return ({ unacknowledged: 'danger', acknowledged: 'warning', recovering: 'primary', recovered: 'success' })[value] || 'info' } +export function canAcknowledge(item) { return item?.state === 'unacknowledged' && item?.version > 0 } +export function canRecover(item) { return item?.state === 'recovering' && item?.version > 0 } +export function buildOpsAlertQuery(query) { + return { + pageIndex: Number(query.pageIndex) || 1, + pageSize: Number(query.pageSize) || 10, + alertType: String(query.alertType || '').trim(), + state: String(query.state || '').trim(), + keyword: String(query.keyword || '').trim() + } +} diff --git a/Sense/ui/tests/unit/sense/opsAlertApi.spec.js b/Sense/ui/tests/unit/sense/opsAlertApi.spec.js new file mode 100644 index 0000000..3d88c4b --- /dev/null +++ b/Sense/ui/tests/unit/sense/opsAlertApi.spec.js @@ -0,0 +1,18 @@ +import request from '@/utils/request' +import { acknowledgeOpsAlert, evaluateOpsAlerts, getOpsAlert, listOpsAlerts, recoverOpsAlert } from '@/api/sense/ops-alert' + +jest.mock('@/utils/request', () => jest.fn(config => config)) + +describe('Sense operational alert API', () => { + beforeEach(() => request.mockClear()) + + test('exposes read, refresh and optimistic state actions', () => { + const query = { pageIndex: 1, pageSize: 10, state: 'unacknowledged' } + const action = { expectedVersion: 3, reason: '已安排现场人员处理设备故障' } + expect(listOpsAlerts(query)).toEqual({ url: '/api/v1/ops-alerts', method: 'get', params: query }) + expect(getOpsAlert('alert/a b')).toEqual({ url: '/api/v1/ops-alerts/alert%2Fa%20b', method: 'get' }) + expect(evaluateOpsAlerts()).toEqual({ url: '/api/v1/ops-alerts/evaluate', method: 'post' }) + expect(acknowledgeOpsAlert('alert/a b', action)).toEqual({ url: '/api/v1/ops-alerts/alert%2Fa%20b/acknowledge', method: 'post', data: action }) + expect(recoverOpsAlert('alert/a b', action)).toEqual({ url: '/api/v1/ops-alerts/alert%2Fa%20b/recover', method: 'post', data: action }) + }) +}) diff --git a/Sense/ui/tests/unit/sense/opsAlertState.spec.js b/Sense/ui/tests/unit/sense/opsAlertState.spec.js new file mode 100644 index 0000000..fe87bbf --- /dev/null +++ b/Sense/ui/tests/unit/sense/opsAlertState.spec.js @@ -0,0 +1,25 @@ +import { alertStateLabel, alertStateType, alertTypeLabel, buildOpsAlertQuery, canAcknowledge, canRecover } from '@/views/sense/ops-alert/opsAlertState' + +describe('Sense operational alert presentation state', () => { + test('uses ordinary operations wording and six alert types', () => { + expect(alertTypeLabel('device_offline')).toBe('设备离线') + expect(alertTypeLabel('authentication_failed')).toBe('认证失败') + expect(alertTypeLabel('clock_drift')).toBe('时间漂移') + expect(alertTypeLabel('reconciliation_failed')).toBe('状态对账失败') + expect(alertTypeLabel('media_shard_failed')).toBe('媒体分片异常') + expect(alertTypeLabel('control_tunnel_failed')).toBe('控制隧道异常') + expect(alertStateLabel('recovering')).toBe('恢复观察') + expect(alertStateType('recovered')).toBe('success') + }) + + test('enforces the approved state action boundary', () => { + expect(canAcknowledge({ state: 'unacknowledged', version: 1 })).toBe(true) + expect(canAcknowledge({ state: 'acknowledged', version: 2 })).toBe(false) + expect(canRecover({ state: 'recovering', version: 3 })).toBe(true) + expect(canRecover({ state: 'acknowledged', version: 3 })).toBe(false) + }) + + test('builds an allowlisted query', () => { + expect(buildOpsAlertQuery({ pageIndex: '2', pageSize: 20, alertType: ' clock_drift ', state: ' recovering ', keyword: ' 南门 ', ignored: true })).toEqual({ pageIndex: 2, pageSize: 20, alertType: 'clock_drift', state: 'recovering', keyword: '南门' }) + }) +})