diff --git a/Sense/server/app/admin/router/sense_edge_node.go b/Sense/server/app/admin/router/sense_edge_node.go new file mode 100644 index 0000000..59fba2f --- /dev/null +++ b/Sense/server/app/admin/router/sense_edge_node.go @@ -0,0 +1,19 @@ +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/edge_node" + "git.ilapage.cn/ila/yovision/Sense/server/common/actions" + "git.ilapage.cn/ila/yovision/Sense/server/common/middleware" +) + +func init() { routerCheckRole = append(routerCheckRole, registerSenseEdgeNodeRouter) } + +func registerSenseEdgeNodeRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) { + api := &edge_node.API{} + r := v1.Group("/edge-nodes").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction()) + r.GET("", api.List) + r.GET("/:id", api.Get) +} diff --git a/Sense/server/app/sense/edge_node/apis.go b/Sense/server/app/sense/edge_node/apis.go new file mode 100644 index 0000000..7cca923 --- /dev/null +++ b/Sense/server/app/sense/edge_node/apis.go @@ -0,0 +1,81 @@ +package edge_node + +import ( + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "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" + + "git.ilapage.cn/ila/yovision/Sense/server/common" +) + +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 + } + item, 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, "读取边缘节点详情 "+item.ID) + e.OK(item, "查询成功") +} + +func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) { + if err := WriteAudit(service.Orm, 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("edge node audit failed: %s", err.Error()) + } +} + +func (e *API) writeError(err error) { + switch { + case errors.Is(err, ErrInvalidQuery): + e.Error(http.StatusBadRequest, err, err.Error()) + case errors.Is(err, ErrNotFound): + e.Error(http.StatusNotFound, err, err.Error()) + default: + e.Error(http.StatusInternalServerError, err, "边缘节点查询失败") + } +} diff --git a/Sense/server/app/sense/edge_node/audit.go b/Sense/server/app/sense/edge_node/audit.go new file mode 100644 index 0000000..d16c717 --- /dev/null +++ b/Sense/server/app/sense/edge_node/audit.go @@ -0,0 +1,21 @@ +package edge_node + +import ( + "time" + + "gorm.io/gorm" + + adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models" +) + +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: "edge_node.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/edge_node/dto.go b/Sense/server/app/sense/edge_node/dto.go new file mode 100644 index 0000000..c003919 --- /dev/null +++ b/Sense/server/app/sense/edge_node/dto.go @@ -0,0 +1,65 @@ +package edge_node + +import ( + "time" + + commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto" +) + +const HeartbeatTimeout = 90 * time.Second + +type PageRequest struct { + commonDto.Pagination `search:"-"` +} + +type Summary struct { + Total int `json:"total"` + Online int `json:"online"` + Offline int `json:"offline"` + Recovering int `json:"recovering"` + LoadUsed int `json:"loadUsed"` + LoadCapacity int `json:"loadCapacity"` + BackfillQueueDepth int `json:"backfillQueueDepth"` + HeartbeatTimeout int64 `json:"heartbeatTimeoutSeconds"` +} + +type Response struct { + ID string `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + Status string `json:"status"` + RuntimeVersion string `json:"runtimeVersion"` + ModelVersion string `json:"modelVersion"` + StartedAt time.Time `json:"startedAt"` + UptimeSeconds int64 `json:"uptimeSeconds"` + LastHeartbeatAt time.Time `json:"lastHeartbeatAt"` + LastCollectedAt time.Time `json:"lastCollectedAt"` + FreshnessSeconds int64 `json:"freshnessSeconds"` + Stale bool `json:"stale"` + LoadUsed int `json:"loadUsed"` + LoadCapacity int `json:"loadCapacity"` + ControlTunnelStatus string `json:"controlTunnelStatus"` + ControlTunnelDetail string `json:"controlTunnelDetail"` + ControlLatencyMs int `json:"controlLatencyMs"` + VideoPlaneStatus string `json:"videoPlaneStatus"` + VideoPlaneDetail string `json:"videoPlaneDetail"` + ActiveStreamCount int `json:"activeStreamCount"` + BackfillStatus string `json:"backfillStatus"` + BackfillQueueDepth int `json:"backfillQueueDepth"` + RecoveryPhase string `json:"recoveryPhase"` + ProjectionVersion int64 `json:"projectionVersion"` + Events []Event `json:"events,omitempty"` +} + +type PageResponse struct { + Summary Summary `json:"summary"` + List []Response `json:"list"` + Count int64 `json:"count"` +} + +// HeartbeatSample is an internal Sense adapter input. It is intentionally not +// exposed as an HTTP contract by this task. +type HeartbeatSample struct { + Node + CollectedAt time.Time +} diff --git a/Sense/server/app/sense/edge_node/fixture.go b/Sense/server/app/sense/edge_node/fixture.go new file mode 100644 index 0000000..3984218 --- /dev/null +++ b/Sense/server/app/sense/edge_node/fixture.go @@ -0,0 +1,20 @@ +package edge_node + +import ( + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// SeedSyntheticFixture is explicit test/development data. Production startup +// and migrations never call it. +func SeedSyntheticFixture(db *gorm.DB, now time.Time) error { + now = now.UTC().Truncate(time.Second) + items := []Node{ + {ID: "SEN-EDGE-01", Name: "主楼边缘节点", Location: "主楼机房", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-7 * 24 * time.Hour), LastHeartbeatAt: now.Add(-18 * time.Second), LastCollectedAt: now.Add(-18 * time.Second), LoadUsed: 6, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "控制隧道正常", ControlLatencyMs: 24, VideoPlaneStatus: ChannelReady, VideoPlaneDetail: "6 路视频正常", ActiveStreamCount: 6, BackfillStatus: BackfillIdle, ProjectionVersion: 1}, + {ID: "SEN-EDGE-02", Name: "仓库边缘节点", Location: "仓库弱电间", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-3 * 24 * time.Hour), LastHeartbeatAt: now.Add(-4 * time.Minute), LastCollectedAt: now.Add(-4 * time.Minute), LoadUsed: 4, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "最后已知:控制隧道正常", ControlLatencyMs: 31, VideoPlaneStatus: ChannelReady, VideoPlaneDetail: "最后已知:4 路视频正常", ActiveStreamCount: 4, BackfillStatus: BackfillPending, BackfillQueueDepth: 12, ProjectionVersion: 1}, + {ID: "SEN-EDGE-03", Name: "南门边缘节点", Location: "南门岗亭", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-10 * time.Hour), LastHeartbeatAt: now.Add(-12 * time.Second), LastCollectedAt: now.Add(-12 * time.Second), LoadUsed: 2, LoadCapacity: 8, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "控制隧道已恢复", ControlLatencyMs: 46, VideoPlaneStatus: ChannelUnavailable, VideoPlaneDetail: "视频数据面恢复中", ActiveStreamCount: 0, BackfillStatus: BackfillPending, BackfillQueueDepth: 5, RecoveryPhase: "video_reconnecting", ProjectionVersion: 2}, + } + return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items).Error +} diff --git a/Sense/server/app/sense/edge_node/models.go b/Sense/server/app/sense/edge_node/models.go new file mode 100644 index 0000000..ef506e2 --- /dev/null +++ b/Sense/server/app/sense/edge_node/models.go @@ -0,0 +1,61 @@ +package edge_node + +import ( + "time" + + common "git.ilapage.cn/ila/yovision/Sense/server/common/models" +) + +const ( + StatusOnline = "online" + StatusOffline = "offline" + StatusRecovering = "recovering" + + ChannelReady = "ready" + ChannelUnavailable = "unavailable" + BackfillIdle = "idle" + BackfillPending = "pending" +) + +// Node is the Sense-owned last-known projection of one edge runtime. It is +// deliberately not a shared identity or a cross-product protocol model. +type Node struct { + ID string `gorm:"size:64;primaryKey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Location string `gorm:"size:256;not null;default:''" json:"location"` + RuntimeVersion string `gorm:"size:64;not null;default:''" json:"runtimeVersion"` + ModelVersion string `gorm:"size:64;not null;default:''" json:"modelVersion"` + StartedAt time.Time `gorm:"not null" json:"startedAt"` + LastHeartbeatAt time.Time `gorm:"not null;index" json:"lastHeartbeatAt"` + LastCollectedAt time.Time `gorm:"not null" json:"lastCollectedAt"` + LoadUsed int `gorm:"not null;default:0" json:"loadUsed"` + LoadCapacity int `gorm:"not null;default:0" json:"loadCapacity"` + ControlTunnelStatus string `gorm:"size:32;not null;default:'unavailable'" json:"controlTunnelStatus"` + ControlTunnelDetail string `gorm:"size:256;not null;default:''" json:"controlTunnelDetail"` + ControlLatencyMs int `gorm:"not null;default:0" json:"controlLatencyMs"` + VideoPlaneStatus string `gorm:"size:32;not null;default:'unavailable'" json:"videoPlaneStatus"` + VideoPlaneDetail string `gorm:"size:256;not null;default:''" json:"videoPlaneDetail"` + ActiveStreamCount int `gorm:"not null;default:0" json:"activeStreamCount"` + BackfillStatus string `gorm:"size:32;not null;default:'idle'" json:"backfillStatus"` + BackfillQueueDepth int `gorm:"not null;default:0" json:"backfillQueueDepth"` + RecoveryPhase string `gorm:"size:32;not null;default:''" json:"recoveryPhase"` + ProjectionVersion int64 `gorm:"not null;default:1" json:"projectionVersion"` + common.ControlBy + common.ModelTime +} + +func (Node) TableName() string { return "sense_edge_nodes" } + +// Event records projection state transitions without storing credentials or +// raw heartbeat payloads. +type Event struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + NodeID string `gorm:"size:64;not null;index" json:"nodeId"` + EventType string `gorm:"size:32;not null;index" json:"eventType"` + FromStatus string `gorm:"size:32;not null;default:''" json:"fromStatus"` + ToStatus string `gorm:"size:32;not null" json:"toStatus"` + Detail string `gorm:"size:256;not null;default:''" json:"detail"` + OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"` +} + +func (Event) TableName() string { return "sense_edge_node_events" } diff --git a/Sense/server/app/sense/edge_node/service.go b/Sense/server/app/sense/edge_node/service.go new file mode 100644 index 0000000..c003d55 --- /dev/null +++ b/Sense/server/app/sense/edge_node/service.go @@ -0,0 +1,167 @@ +package edge_node + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + coreService "github.com/go-admin-team/go-admin-core/sdk/service" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrInvalidQuery = errors.New("边缘节点查询条件不符合要求") + ErrNotFound = errors.New("边缘节点不存在") + ErrInvalidSample = errors.New("边缘节点心跳样本不符合要求") +) + +type Service struct { + coreService.Service + Now func() time.Time +} + +func NewService(db *gorm.DB) *Service { + return &Service{Service: coreService.Service{Orm: db}, Now: time.Now} +} + +func (s *Service) List(request PageRequest) (PageResponse, error) { + if request.GetPageSize() > 100 { + return PageResponse{}, ErrInvalidQuery + } + var count int64 + if err := s.Orm.Model(&Node{}).Count(&count).Error; err != nil { + return PageResponse{}, fmt.Errorf("count edge nodes: %w", err) + } + var models []Node + if err := s.Orm.Order("name ASC, id ASC").Limit(request.GetPageSize()).Offset((request.GetPageIndex() - 1) * request.GetPageSize()).Find(&models).Error; err != nil { + return PageResponse{}, fmt.Errorf("list edge nodes: %w", err) + } + result := PageResponse{List: make([]Response, 0, len(models)), Count: count} + result.Summary.HeartbeatTimeout = int64(HeartbeatTimeout / time.Second) + var all []Node + if err := s.Orm.Find(&all).Error; err != nil { + return PageResponse{}, fmt.Errorf("summarize edge nodes: %w", err) + } + result.Summary.Total = len(all) + for _, item := range all { + status := s.status(item) + switch status { + case StatusOffline: + result.Summary.Offline++ + case StatusRecovering: + result.Summary.Recovering++ + default: + result.Summary.Online++ + } + result.Summary.LoadUsed += item.LoadUsed + result.Summary.LoadCapacity += item.LoadCapacity + result.Summary.BackfillQueueDepth += item.BackfillQueueDepth + } + for _, item := range models { + result.List = append(result.List, s.response(item, nil)) + } + return result, nil +} + +func (s *Service) Get(id string) (Response, error) { + id = strings.TrimSpace(id) + if id == "" || len(id) > 64 { + return Response{}, ErrInvalidQuery + } + var model Node + if err := s.Orm.First(&model, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return Response{}, ErrNotFound + } + return Response{}, fmt.Errorf("get edge node: %w", err) + } + var events []Event + if err := s.Orm.Where("node_id = ?", id).Order("occurred_at DESC, id DESC").Limit(20).Find(&events).Error; err != nil { + return Response{}, fmt.Errorf("list edge node events: %w", err) + } + return s.response(model, events), nil +} + +// ApplyHeartbeat updates the Sense-owned projection atomically and records +// only state transitions. Callers are internal adapters, not public APIs. +func (s *Service) ApplyHeartbeat(ctx context.Context, sample HeartbeatSample) error { + now := sample.CollectedAt.UTC() + if now.IsZero() { + now = s.now() + } + sample.ID = strings.TrimSpace(sample.ID) + if sample.ID == "" || len(sample.ID) > 64 || strings.TrimSpace(sample.Name) == "" || sample.LastHeartbeatAt.IsZero() || sample.StartedAt.IsZero() || sample.LoadUsed < 0 || sample.LoadCapacity < 0 || sample.LoadUsed > sample.LoadCapacity || sample.BackfillQueueDepth < 0 { + return ErrInvalidSample + } + sample.LastHeartbeatAt = sample.LastHeartbeatAt.UTC() + sample.LastCollectedAt = now + return s.Orm.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var previous Node + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&previous, "id = ?", sample.ID).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("lock edge node projection: %w", err) + } + from := "" + if err == nil { + from = statusAt(previous, now) + sample.CreatedAt = previous.CreatedAt + sample.ProjectionVersion = previous.ProjectionVersion + 1 + } else { + sample.ProjectionVersion = 1 + } + if err = tx.Save(&sample.Node).Error; err != nil { + return fmt.Errorf("save edge node projection: %w", err) + } + to := statusAt(sample.Node, now) + if from == to && from != "" { + return nil + } + if from == StatusOffline { + timeoutAt := previous.LastHeartbeatAt.UTC().Add(HeartbeatTimeout) + if err = tx.Create(&Event{NodeID: sample.ID, EventType: "heartbeat_timeout", FromStatus: StatusOnline, ToStatus: StatusOffline, Detail: "节点心跳超过 90 秒未更新", OccurredAt: timeoutAt}).Error; err != nil { + return err + } + } + eventType, detail := "heartbeat_received", "收到节点首次心跳" + if from == StatusOffline { + eventType, detail = "node_recovered", "节点心跳恢复,通道状态继续独立收敛" + } else if to == StatusRecovering { + eventType, detail = "recovery_started", "节点进入恢复阶段" + } + return tx.Create(&Event{NodeID: sample.ID, EventType: eventType, FromStatus: from, ToStatus: to, Detail: detail, OccurredAt: now}).Error + }) +} + +func (s *Service) response(model Node, events []Event) Response { + now := s.now() + freshness := int64(now.Sub(model.LastHeartbeatAt.UTC()).Seconds()) + if freshness < 0 { + freshness = 0 + } + uptime := int64(model.LastHeartbeatAt.UTC().Sub(model.StartedAt.UTC()).Seconds()) + if uptime < 0 { + uptime = 0 + } + return Response{ID: model.ID, Name: model.Name, Location: model.Location, Status: statusAt(model, now), RuntimeVersion: model.RuntimeVersion, ModelVersion: model.ModelVersion, StartedAt: model.StartedAt, UptimeSeconds: uptime, LastHeartbeatAt: model.LastHeartbeatAt, LastCollectedAt: model.LastCollectedAt, FreshnessSeconds: freshness, Stale: freshness > int64(HeartbeatTimeout/time.Second), LoadUsed: model.LoadUsed, LoadCapacity: model.LoadCapacity, ControlTunnelStatus: model.ControlTunnelStatus, ControlTunnelDetail: model.ControlTunnelDetail, ControlLatencyMs: model.ControlLatencyMs, VideoPlaneStatus: model.VideoPlaneStatus, VideoPlaneDetail: model.VideoPlaneDetail, ActiveStreamCount: model.ActiveStreamCount, BackfillStatus: model.BackfillStatus, BackfillQueueDepth: model.BackfillQueueDepth, RecoveryPhase: model.RecoveryPhase, ProjectionVersion: model.ProjectionVersion, Events: events} +} + +func (s *Service) status(model Node) string { return statusAt(model, s.now()) } +func (s *Service) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} + +func statusAt(model Node, now time.Time) string { + if now.UTC().Sub(model.LastHeartbeatAt.UTC()) > HeartbeatTimeout { + return StatusOffline + } + if strings.TrimSpace(model.RecoveryPhase) != "" && model.RecoveryPhase != "converged" { + return StatusRecovering + } + return StatusOnline +} diff --git a/Sense/server/app/sense/edge_node/service_test.go b/Sense/server/app/sense/edge_node/service_test.go new file mode 100644 index 0000000..bc87002 --- /dev/null +++ b/Sense/server/app/sense/edge_node/service_test.go @@ -0,0 +1,103 @@ +package edge_node + +import ( + "context" + "errors" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func edgeNodeTestService(t *testing.T) (*Service, *gorm.DB, time.Time) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&Node{}, &Event{}); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 28, 4, 0, 0, 0, time.UTC) + service := NewService(db) + service.Now = func() time.Time { return now } + return service, db, now +} + +func TestListClassifiesOnlineOfflineAndRecoveringWithoutErasingLastKnownState(t *testing.T) { + service, db, now := edgeNodeTestService(t) + if err := SeedSyntheticFixture(db, now); err != nil { + t.Fatal(err) + } + page, err := service.List(PageRequest{}) + if err != nil { + t.Fatal(err) + } + if page.Count != 3 || page.Summary.Online != 1 || page.Summary.Offline != 1 || page.Summary.Recovering != 1 || page.Summary.BackfillQueueDepth != 17 { + t.Fatalf("unexpected summary: %+v", page.Summary) + } + var offline Response + for _, item := range page.List { + if item.Status == StatusOffline { + offline = item + } + } + if !offline.Stale || offline.ControlTunnelStatus != ChannelReady || offline.VideoPlaneStatus != ChannelReady || offline.BackfillQueueDepth != 12 { + t.Fatalf("offline projection lost last-known state: %+v", offline) + } +} + +func TestApplyHeartbeatRecordsRecoveryAndPreservesIndependentChannelConvergence(t *testing.T) { + service, db, now := edgeNodeTestService(t) + old := Node{ID: "EDGE-1", Name: "旧节点", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now.Add(-5 * time.Minute), LastCollectedAt: now.Add(-5 * time.Minute), LoadCapacity: 16, ControlTunnelStatus: ChannelReady, VideoPlaneStatus: ChannelReady, BackfillStatus: BackfillPending, BackfillQueueDepth: 8, ProjectionVersion: 3} + if err := db.Create(&old).Error; err != nil { + t.Fatal(err) + } + sample := HeartbeatSample{Node: Node{ID: "EDGE-1", Name: "旧节点", StartedAt: old.StartedAt, LastHeartbeatAt: now.Add(-5 * time.Second), LoadUsed: 4, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, VideoPlaneStatus: ChannelUnavailable, BackfillStatus: BackfillPending, BackfillQueueDepth: 3, RecoveryPhase: "video_reconnecting"}, CollectedAt: now} + if err := service.ApplyHeartbeat(context.Background(), sample); err != nil { + t.Fatal(err) + } + item, err := service.Get("EDGE-1") + if err != nil { + t.Fatal(err) + } + if item.Status != StatusRecovering || item.ProjectionVersion != 4 || item.VideoPlaneStatus != ChannelUnavailable || item.BackfillQueueDepth != 3 { + t.Fatalf("unexpected recovery projection: %+v", item) + } + if len(item.Events) != 2 || item.Events[0].EventType != "node_recovered" || item.Events[0].FromStatus != StatusOffline || item.Events[0].ToStatus != StatusRecovering || item.Events[1].EventType != "heartbeat_timeout" || item.Events[1].ToStatus != StatusOffline { + t.Fatalf("unexpected events: %+v", item.Events) + } +} + +func TestApplyHeartbeatRejectsInvalidSampleAndDoesNotPersist(t *testing.T) { + service, db, now := edgeNodeTestService(t) + err := service.ApplyHeartbeat(context.Background(), HeartbeatSample{Node: Node{ID: "EDGE-2", Name: "节点", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now, LoadUsed: 17, LoadCapacity: 16}, CollectedAt: now}) + if !errors.Is(err, ErrInvalidSample) { + t.Fatalf("err=%v", err) + } + var count int64 + db.Model(&Node{}).Count(&count) + if count != 0 { + t.Fatalf("count=%d", count) + } +} + +func TestGetRejectsMissingOrMalformedID(t *testing.T) { + service, _, _ := edgeNodeTestService(t) + if _, err := service.Get(""); !errors.Is(err, ErrInvalidQuery) { + t.Fatalf("err=%v", err) + } + if _, err := service.Get("missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err=%v", err) + } +} + +func TestListRejectsOversizedPage(t *testing.T) { + service, _, _ := edgeNodeTestService(t) + request := PageRequest{} + request.PageSize = 101 + if _, err := service.List(request); !errors.Is(err, ErrInvalidQuery) { + t.Fatalf("err=%v", err) + } +} diff --git a/Sense/server/cmd/migrate/migration/version/2026082813000_edge_node.go b/Sense/server/cmd/migrate/migration/version/2026082813000_edge_node.go new file mode 100644 index 0000000..a67f453 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026082813000_edge_node.go @@ -0,0 +1,53 @@ +package version + +import ( + "fmt" + "runtime" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/edge_node" + "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" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseEdgeNode) +} + +func migrateSenseEdgeNode(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&edge_node.Node{}, &edge_node.Event{}); 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: "SenseEdgeNode", Title: "边缘节点", Icon: "monitor", Path: "edge-node", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:edge-node:list", ParentId: root.MenuId, Component: "/sense/edge-node/index", Sort: 9, Visible: "0", IsFrame: "1"}) + if err != nil { + return err + } + detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseEdgeNodeDetail", Title: "查看节点详情", MenuType: "F", Action: "GET", Permission: "sense:edge-node: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 + } + 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 [][2]string{{"/api/v1/edge-nodes", "GET"}, {"/api/v1/edge-nodes/:id", "GET"}} { + 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/2026082813000_edge_node_test.go b/Sense/server/cmd/migrate/migration/version/2026082813000_edge_node_test.go new file mode 100644 index 0000000..199a79a --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026082813000_edge_node_test.go @@ -0,0 +1,90 @@ +package version + +import ( + "os" + "testing" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/edge_node" + migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models" + common "git.ilapage.cn/ila/yovision/Sense/server/common/models" +) + +func TestEdgeNodeMigrationAddsTablesAndReadOnlyPermissions(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 = "2026082813000_edge_node.go" + if err = migrateSenseEdgeNode(db, version); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable(&edge_node.Node{}) || !db.Migrator().HasTable(&edge_node.Event{}) { + t.Fatal("edge node tables missing") + } + var menus, reads, writes, applied int64 + db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseEdgeNode%").Count(&menus) + db.Model(&deviceCasbinRule{}).Where("v1 LIKE ? AND v2 = ?", "/api/v1/edge-nodes%", "GET").Count(&reads) + db.Model(&deviceCasbinRule{}).Where("v1 LIKE ? AND v2 <> ?", "/api/v1/edge-nodes%", "GET").Count(&writes) + db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied) + if menus != 2 || reads != 6 || writes != 0 || applied != 1 { + t.Fatalf("menus=%d reads=%d writes=%d applied=%d", menus, reads, writes, applied) + } + var fixtures int64 + db.Model(&edge_node.Node{}).Count(&fixtures) + if fixtures != 0 { + t.Fatalf("migration seeded %d fixtures", fixtures) + } +} + +func TestEdgeNodeMigrationOnPostgres(t *testing.T) { + dsn := os.Getenv("SENSE_EDGE_NODE_MIGRATION_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set SENSE_EDGE_NODE_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + const schema = "sense_edge_node_76_test" + if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil { + t.Fatal(err) + } + if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error }) + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + if err = db.Exec("SET search_path TO " + schema).Error; 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) + } + } + if err = migrateSenseEdgeNode(db, "2026082813000_edge_node.go"); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable(&edge_node.Node{}) || !db.Migrator().HasTable(&edge_node.Event{}) { + t.Fatal("edge node tables missing") + } +} diff --git a/Sense/ui/src/api/sense/edge-node.js b/Sense/ui/src/api/sense/edge-node.js new file mode 100644 index 0000000..d4b11ce --- /dev/null +++ b/Sense/ui/src/api/sense/edge-node.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +export function listEdgeNodes(query) { + return request({ url: '/api/v1/edge-nodes', method: 'get', params: query }) +} + +export function getEdgeNode(id) { + return request({ url: `/api/v1/edge-nodes/${encodeURIComponent(id)}`, method: 'get' }) +} diff --git a/Sense/ui/src/views/sense/edge-node/edgeNodeState.js b/Sense/ui/src/views/sense/edge-node/edgeNodeState.js new file mode 100644 index 0000000..f0e357c --- /dev/null +++ b/Sense/ui/src/views/sense/edge-node/edgeNodeState.js @@ -0,0 +1,36 @@ +const stateLabels = { + online: '在线', + offline: '离线', + recovering: '恢复中', + ready: '正常', + unavailable: '不可用', + idle: '无待回填', + pending: '等待回填' +} + +const eventLabels = { + heartbeat_received: '首次心跳', + heartbeat_timeout: '心跳超时', + node_recovered: '心跳恢复', + recovery_started: '开始恢复' +} + +export function stateLabel(value) { return stateLabels[value] || value || '未知' } +export function eventLabel(value) { return eventLabels[value] || value || '状态变化' } +export function stateType(value) { return ({ online: 'success', offline: 'danger', recovering: 'warning', ready: 'success', unavailable: 'danger', idle: 'info', pending: 'warning' })[value] || 'info' } +export function freshnessText(item) { + if (!item?.lastHeartbeatAt) return '从未收到心跳' + const seconds = Math.max(0, Number(item.freshnessSeconds) || 0) + if (item.stale) return `已陈旧 ${durationText(seconds)}` + return `${durationText(seconds)}前` +} +export function durationText(seconds) { + const value = Math.max(0, Number(seconds) || 0) + if (value < 60) return `${Math.floor(value)} 秒` + if (value < 3600) return `${Math.floor(value / 60)} 分钟` + if (value < 86400) return `${Math.floor(value / 3600)} 小时` + return `${Math.floor(value / 86400)} 天` +} +export function buildEdgeNodeQuery(query) { + return { pageIndex: Number(query.pageIndex) || 1, pageSize: Number(query.pageSize) || 10 } +} diff --git a/Sense/ui/src/views/sense/edge-node/index.vue b/Sense/ui/src/views/sense/edge-node/index.vue new file mode 100644 index 0000000..386b949 --- /dev/null +++ b/Sense/ui/src/views/sense/edge-node/index.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/Sense/ui/tests/unit/sense/edgeNodeApi.spec.js b/Sense/ui/tests/unit/sense/edgeNodeApi.spec.js new file mode 100644 index 0000000..dcfb727 --- /dev/null +++ b/Sense/ui/tests/unit/sense/edgeNodeApi.spec.js @@ -0,0 +1,14 @@ +import request from '@/utils/request' +import { getEdgeNode, listEdgeNodes } from '@/api/sense/edge-node' + +jest.mock('@/utils/request', () => jest.fn()) + +describe('Sense edge node API', () => { + beforeEach(() => request.mockReset()) + test('uses read-only endpoints and encodes identifiers', () => { + listEdgeNodes({ pageIndex: 1, pageSize: 10 }) + getEdgeNode('edge/site 01') + expect(request).toHaveBeenNthCalledWith(1, { url: '/api/v1/edge-nodes', method: 'get', params: { pageIndex: 1, pageSize: 10 } }) + expect(request).toHaveBeenNthCalledWith(2, { url: '/api/v1/edge-nodes/edge%2Fsite%2001', method: 'get' }) + }) +}) diff --git a/Sense/ui/tests/unit/sense/edgeNodeState.spec.js b/Sense/ui/tests/unit/sense/edgeNodeState.spec.js new file mode 100644 index 0000000..401cea0 --- /dev/null +++ b/Sense/ui/tests/unit/sense/edgeNodeState.spec.js @@ -0,0 +1,18 @@ +import { buildEdgeNodeQuery, durationText, eventLabel, freshnessText, stateLabel, stateType } from '@/views/sense/edge-node/edgeNodeState' + +describe('Sense edge node presentation state', () => { + test('uses operator-facing connection and channel labels', () => { + expect(stateLabel('offline')).toBe('离线') + expect(stateLabel('recovering')).toBe('恢复中') + expect(stateType('offline')).toBe('danger') + expect(eventLabel('node_recovered')).toBe('心跳恢复') + }) + test('makes stale last-known data explicit', () => { + expect(freshnessText({ stale: true, freshnessSeconds: 245, lastHeartbeatAt: '2026-08-28T00:00:00Z' })).toBe('已陈旧 4 分钟') + expect(freshnessText({ stale: false, freshnessSeconds: 18, lastHeartbeatAt: '2026-08-28T00:00:00Z' })).toBe('18 秒前') + expect(durationText(172800)).toBe('2 天') + }) + test('sends only allowlisted pagination fields', () => { + expect(buildEdgeNodeQuery({ pageIndex: '2', pageSize: 20, ignored: 'not-sent' })).toEqual({ pageIndex: 2, pageSize: 20 }) + }) +}) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 66120f8..3bb4a70 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: 502d61f5c765c6c9b71432cdbbf0159c9b9c1071 -synchronized_at: 2026-08-28T03:50:51Z +wiki_revision: 5c2b5df080b160c1174be3e935fffba420885bff +synchronized_at: 2026-08-28T06:14:33Z # 架构与代码地图 @@ -215,3 +215,13 @@ PostgreSQL 表 `sense_provisioning_batches` 保存幂等键、配额快照和汇 - 前端:`Sense/ui/src/views/sense/quota/` 与 `Sense/ui/src/api/sense/quota.js`;复用 BasicLayout、Axios、Element Plus Alert/Row/Progress/Form/Table/Pagination/Dialog/Tag/Button 和权限指令。 - 本模块不导入 Brain/Bell 模型,不访问其数据库,也不定义跨项目配额契约。 + + +## Sense 边缘节点代码路径 + +- 领域投影、DTO、服务、审计、合成夹具与测试:`Sense/server/app/sense/edge_node/`。 +- GoAdmin 路由:`Sense/server/app/admin/router/sense_edge_node.go`;只开放 `GET /api/v1/edge-nodes` 与 `GET /api/v1/edge-nodes/:id`,复用 JWT、Casbin、PermissionAction 和统一响应封装。 +- PostgreSQL 表:`sense_edge_nodes` 保存最后已知投影,`sense_edge_node_events` 保存不可变状态转换;迁移 `2026082813000_edge_node.go` 建立动态菜单及 implementation_operator/site_admin/viewer 的只读权限,不写入夹具。 +- go-admin-ui 页面:`Sense/ui/src/views/sense/edge-node/`;API 封装:`Sense/ui/src/api/sense/edge-node.js`。页面复用 BasicLayout、Element Plus 表格/分页/Dialog/Tag/Alert 和权限指令。 +- 内部 `ApplyHeartbeat` 是未来 Sense 适配器的投影写入口,本工单不把它暴露为 HTTP API,也不定义跨项目契约。 + diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 8e40edf..6ff9036 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.- -wiki_revision: f7a08ae4eaa7557f2b8a470d895b06567f77d0e1 -synchronized_at: 2026-08-28T03:51:05Z +wiki_revision: 8f81b925af671209341f1282e128c7f048bca8ec +synchronized_at: 2026-08-28T06:14:44Z # 业务规则与术语 @@ -188,3 +188,14 @@ synchronized_at: 2026-08-28T03:51:05Z - 32/64/128 只展示当前配置和目标硬件性能测试状态。未验证不得解释为支持或稳定承载承诺。 - 配额调整必须记录旧值、新值、操作人、时间和原因;viewer 与实施/运维角色只读,只有站点管理员可调整配额和重新启用设备。 + + +## Sense 边缘节点状态规则 + +- **边缘节点投影**:Sense 保存的节点最后已知运行状态,不是 Brain/Bell 的共享机器身份或跨产品事实源。 +- **在线**:最近心跳距读取时刻不超过 90 秒,且没有未完成的恢复阶段。 +- **离线**:最近心跳距读取时刻超过 90 秒。离线是读取时推导状态,不得用“未知”覆盖最后已知的控制隧道、视频数据面、负载和回填队列;所有这些值必须同时标明陈旧。 +- **恢复中**:心跳已经恢复,但控制隧道、视频数据面或回填通道仍在独立收敛。只有恢复阶段明确收敛后才显示在线。 +- 状态转换写入不可变事件;列表和详情读取写入脱敏操作审计,不记录凭据或原始心跳载荷。 +- 合成夹具必须显式调用,生产迁移和启动不得自动写入节点。 + diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index eb07b03..35fa9e4 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.- -wiki_revision: e1755deff68c95188e6bf2f759c7ec93293c8715 -synchronized_at: 2026-08-27T15:22:48Z +wiki_revision: cfec18809b3cb0d938003102c9caa5e6d6f4e818 +synchronized_at: 2026-08-28T06:25:12Z # 本地开发与验证 @@ -399,3 +399,81 @@ D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf reload 当前 Go Supervisor 的 `reload` 会重新读取独立配置;实际受影响实例必须以命令输出和 reload 前后 PID 为准。切换托管前先停止占用 Sense 端口的非 Supervisor 实例,防止自动启动进入 Backoff。验证至少包含 Supervisor 状态为 Running、`http://127.0.0.1:18080/health` 与首页返回 200,以及受控重启后 Sense 和受管 MediaMTX PID 均更新。 + + +### Sense 本地事件验证 + +合成数据通过 `local_event.SeedSyntheticFixture` 在测试中显式装载,不存在生产自动开关,也不要求 Brain 或 Bell 进程。定向及回归命令: + +```powershell +cd Sense/server +go test -race ./app/sense/local_event +go test ./... +go vet ./... +go build ./... + +cd ../ui +corepack pnpm@9.15.1 lint +corepack pnpm@9.15.1 test:unit +corepack pnpm@9.15.1 build:prod +``` + +PostgreSQL 迁移测试使用独立 schema;连接值只放当前进程环境,不写入仓库或日志: + +```powershell +$env:SENSE_LOCAL_EVENT_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>' +go test ./cmd/migrate/migration/version -run TestLocalEventMigrationOnPostgres -count=1 -v +``` + +验证应覆盖:candidate/confirmed 与 pending/success/failed 的独立组合、分页和时间/状态/关键词筛选、详情及保留提示、viewer/实施/站点管理员只读权限、操作审计,以及 Brain/Bell 均未启动时的合成候选 smoke。没有可用 PostgreSQL 测试连接时必须明确记录为未验证,不得以 SQLite 单测替代 PostgreSQL 结论。 + + + +### Sense 运维中心验证 + +定向验证: + +```powershell +cd Sense/server +go test -race ./app/sense/operations +go test ./app/admin/router ./cmd/migrate/migration/version +go test ./... +go vet ./... +go build ./... + +cd ../ui +corepack pnpm@9.15.1 lint +corepack pnpm@9.15.1 test:unit +corepack pnpm@9.15.1 build:prod +``` + +合成 smoke 应覆盖设备认证失败、设备时间漂移、媒体退避、孤儿路由、本地推理 unavailable、筛选分页、详情、设备同步探测重试、媒体排队重试、版本冲突、在途防重和脱敏审计。Brain/Bell 不应启动或成为测试依赖。真实摄像头与 MediaMTX 故障恢复仍须在获准环境验证,日志不得记录设备地址、Stream URI 或凭据。 + + + +### Sense 边缘节点验证 + +定向验证: + +```powershell +cd Sense/server +go test -race ./app/sense/edge_node +go test ./cmd/migrate/migration/version -run EdgeNode +go test ./... +go vet ./... + +cd ../ui +corepack pnpm@9.15.1 lint +corepack pnpm@9.15.1 test:unit +corepack pnpm@9.15.1 build:prod +``` + +PostgreSQL 迁移测试只在提供隔离连接时执行: + +```powershell +$env:SENSE_EDGE_NODE_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>' +go test ./cmd/migrate/migration/version -run TestEdgeNodeMigrationOnPostgres -count=1 -v +``` + +验证应覆盖在线、超过 90 秒的离线、心跳恢复但通道未收敛、最后已知状态与陈旧时长、负载和回填队列、不可变状态事件、只读权限、脱敏读取审计,以及 Brain/Bell 不运行时的独立性。合成节点只能由测试或显式开发调用;没有隔离 PostgreSQL 连接时必须记录迁移真库测试未执行。 + diff --git a/docs/09-product-requirements.md b/docs/09-product-requirements.md index 0018a8b..b8db86c 100644 --- a/docs/09-product-requirements.md +++ b/docs/09-product-requirements.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.- -wiki_revision: e298b5a31fcc39e3e2d9b4df543a4bc50d94bcaa -synchronized_at: 2026-08-28T03:53:13Z +wiki_revision: dcbdcf563017a1749fa76ad5f78c74a2c3cc6be1 +synchronized_at: 2026-08-28T06:16:00Z # 产品需求 @@ -224,3 +224,11 @@ SEN-011 将默认 16 路实现为可配置的 Sense 本地交付配额。统一 容量页面只展示 16/32/64/128 的当前配置和性能测试状态。除已经验证的 16 路学校试点基线外,其余档位在完成目标硬件压测前都不得解释为单机承载承诺。 + + +## Sense 边缘节点资产与离线状态 + +Sense 为网管和非技术运维人员提供只读的“边缘节点”页面,集中查看节点名称、位置、运行版本、模型版本、运行时长、承载路数、控制隧道、视频数据面和回填队列。节点最近心跳超过 90 秒即在读取时显示为离线;离线不会清空最后一次采集到的通道和队列状态,页面必须同时标明陈旧时长。心跳恢复后,连接状态与控制、视频、回填通道分别收敛,未全部收敛时显示“恢复中”。列表、详情及状态变化均可审计。 + +本能力只管理 Sense 自有投影,不建立 Brain/Bell 共享身份、控制协议或跨项目回填执行;Brain、Bell 未运行时仍可独立查看。合成节点仅供显式开发和测试,不由生产启动或迁移自动写入。 + diff --git a/docs/delivery/deployment-and-operations.md b/docs/delivery/deployment-and-operations.md index 84004cf..f96afd0 100644 --- a/docs/delivery/deployment-and-operations.md +++ b/docs/delivery/deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.- -wiki_revision: 95965345e43d7fc403486c8afaecf36d79b757d0 -synchronized_at: 2026-08-28T03:53:21Z +wiki_revision: 34b85f1c3fc2c3e0e7fa0de3df056940fcb71333 +synchronized_at: 2026-08-28T06:17:09Z # YoVision 部署与运维 @@ -92,3 +92,11 @@ Sense\start_sense.bat 批量导入被拒绝时还应检查模板只含 line_number/name/location/address,地址必须为不带账号、查询参数或片段的 HTTP(S) ONVIF 地址。条目失败时按页面原因检查网络、获准网段、凭据和接入状态;仅重试失败项,不删除已成功设备。日志、导出和问题记录不得粘贴摄像头凭据。 + + +## Sense 边缘节点状态排错 + +升级后看不到“边缘节点”菜单时,先确认数据库迁移 `2026082813000_edge_node.go` 已成功,再重新登录或刷新动态菜单。implementation_operator、site_admin、viewer 均只有列表和详情读取权限,本模块没有网页写入、删除或远程控制接口。 + +节点超过 90 秒没有心跳即显示离线。离线时页面继续显示最后已知的隧道、视频、负载和回填状态,并标明陈旧时长,这不表示通道仍实时可用。心跳恢复但通道未收敛时显示“恢复中”,应分别检查控制隧道、视频数据面和回填队列。当前版本只提供 Sense 内部投影入口,不包含外部心跳接入协议或跨节点回填执行;没有节点数据时不会自动生成演示节点。 +