Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db49fc955 | ||
|
|
a9fd8e8a47 | ||
|
|
12f5419f21 | ||
|
|
debf662138 |
@@ -22,6 +22,7 @@ func registerSenseDeviceRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.POST("", api.Insert)
|
||||
r.PUT("/:id", api.Update)
|
||||
r.PUT("/:id/disable", api.Disable)
|
||||
r.PUT("/:id/enable", api.Enable)
|
||||
r.PUT("/:id/credentials", api.UpdateCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/quota"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseQuotaRouter) }
|
||||
|
||||
func registerSenseQuotaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := "a.API{}
|
||||
r := v1.Group("/quota").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.Get)
|
||||
r.PUT("", api.Update)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
|
||||
)
|
||||
|
||||
type Device struct{ api.Api }
|
||||
@@ -106,6 +107,25 @@ func (e Device) Disable(c *gin.Context) {
|
||||
e.OK(response, "设备已停用")
|
||||
}
|
||||
|
||||
func (e Device) Enable(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.EnableReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Enable(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "设备已启用,请重新完成视频接入验证")
|
||||
}
|
||||
|
||||
func (e Device) UpdateCredentials(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
@@ -133,6 +153,10 @@ func (e Device) writeServiceError(err error) {
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, deviceService.ErrVersionConflict):
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
case errors.Is(err, quota.ErrExceeded):
|
||||
e.Error(http.StatusConflict, err, "当前配额已满,无法新增或启用设备")
|
||||
case errors.Is(err, quota.ErrUnavailable):
|
||||
e.Error(http.StatusServiceUnavailable, err, "配额配置不可读取,已拒绝新增或启用设备")
|
||||
case errors.Is(err, credential.ErrKeyUnavailable):
|
||||
e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
|
||||
default:
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -108,7 +109,12 @@ func (e *Device) Insert(req *dto.CreateReq, response *dto.DeviceResponse) error
|
||||
}
|
||||
model.CreateBy = req.CreateBy
|
||||
model.UpdateBy = req.CreateBy
|
||||
if err = e.Orm.Create(&model).Error; err != nil {
|
||||
if err = quota.WithAvailableSlot(e.Orm, func(tx *gorm.DB) error {
|
||||
return tx.Create(&model).Error
|
||||
}); err != nil {
|
||||
if errors.Is(err, quota.ErrUnavailable) || errors.Is(err, quota.ErrExceeded) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("create device: %w", err)
|
||||
}
|
||||
return e.Get(model.ID, response)
|
||||
@@ -155,6 +161,35 @@ func (e *Device) Disable(req *dto.DisableReq, response *dto.DeviceResponse) erro
|
||||
return e.Get(req.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) Enable(req *dto.EnableReq, response *dto.DeviceResponse) error {
|
||||
if req.Version < 1 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
err := quota.WithAvailableSlot(e.Orm, func(tx *gorm.DB) error {
|
||||
result := tx.Model(&models.Device{}).
|
||||
Where("id = ? AND version = ? AND status = ?", req.ID, req.Version, models.StatusDisabled).
|
||||
Updates(map[string]any{
|
||||
"status": models.StatusPending,
|
||||
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return e.notFoundOrConflictWith(tx, req.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, quota.ErrUnavailable) || errors.Is(err, quota.ErrExceeded) ||
|
||||
errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrVersionConflict) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("enable device: %w", err)
|
||||
}
|
||||
return e.Get(req.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) UpdateCredentials(req *dto.CredentialUpdateReq, response *dto.DeviceResponse) error {
|
||||
if req.Version < 1 || strings.TrimSpace(req.ONVIFUsername) == "" || req.ONVIFPassword == "" {
|
||||
return ErrInvalidDevice
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
|
||||
)
|
||||
|
||||
func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
|
||||
@@ -21,7 +22,10 @@ func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}); err != nil {
|
||||
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}, "a.Setting{}, "a.Change{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create("a.Setting{ID: quota.SettingID, Limit: 32, Source: "test", Version: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
@@ -36,6 +40,38 @@ func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
|
||||
return service, db
|
||||
}
|
||||
|
||||
func TestCreateAndEnableUseQuotaSafetyGate(t *testing.T) {
|
||||
service, db := testDeviceService(t)
|
||||
if err := db.Model("a.Setting{}).Where("id = ?", quota.SettingID).Update("limit", 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var first dto.DeviceResponse
|
||||
if err := service.Insert(&dto.CreateReq{Name: "第一路", Modality: "video", Capabilities: []string{"video"}}, &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rejected dto.DeviceResponse
|
||||
if err := service.Insert(&dto.CreateReq{Name: "第二路", Modality: "video", Capabilities: []string{"video"}}, &rejected); err != quota.ErrExceeded {
|
||||
t.Fatalf("second create error=%v", err)
|
||||
}
|
||||
var disabled dto.DeviceResponse
|
||||
if err := service.Disable(&dto.DisableReq{ID: first.ID, Version: first.Version}, &disabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var second dto.DeviceResponse
|
||||
if err := service.Insert(&dto.CreateReq{Name: "第二路", Modality: "video", Capabilities: []string{"video"}}, &second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Enable(&dto.EnableReq{ID: disabled.ID, Version: disabled.Version}, &rejected); err != quota.ErrExceeded {
|
||||
t.Fatalf("enable over quota error=%v", err)
|
||||
}
|
||||
if err := db.Delete("a.Setting{}, quota.SettingID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Insert(&dto.CreateReq{Name: "第三路", Modality: "video", Capabilities: []string{"video"}}, &rejected); err != quota.ErrUnavailable {
|
||||
t.Fatalf("create with unreadable quota error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLifecycleUsesAllowlistedFieldsAndOptimisticVersion(t *testing.T) {
|
||||
service, _ := testDeviceService(t)
|
||||
var created dto.DeviceResponse
|
||||
|
||||
@@ -36,6 +36,12 @@ type DisableReq struct {
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type EnableReq struct {
|
||||
ID string `json:"-"`
|
||||
Version int64 `json:"version"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type CredentialUpdateReq struct {
|
||||
ID string `json:"-"`
|
||||
ONVIFUsername string `json:"onvifUsername"`
|
||||
|
||||
@@ -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, "边缘节点查询失败")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"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"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
|
||||
)
|
||||
|
||||
type API struct{ api.Api }
|
||||
@@ -137,6 +139,10 @@ func (e *API) writeError(err error) {
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrBatchNotFound), errors.Is(err, ErrItemNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, quota.ErrExceeded):
|
||||
e.Error(http.StatusConflict, err, "当前配额已满,无法继续批量开通")
|
||||
case errors.Is(err, quota.ErrUnavailable):
|
||||
e.Error(http.StatusServiceUnavailable, err, "配额配置不可读取,已拒绝批量开通写入")
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "批量开通操作失败")
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +18,7 @@ import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
|
||||
deviceDTO "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -36,23 +35,11 @@ type Service struct {
|
||||
Activator Activator
|
||||
}
|
||||
|
||||
func QuotaFromEnvironment() int {
|
||||
value := strings.TrimSpace(os.Getenv("SENSE_PROVISIONING_QUOTA"))
|
||||
if value == "" {
|
||||
return 16
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 || parsed > 100000 {
|
||||
return 16
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (s *Service) quota() int {
|
||||
func (s *Service) quotaLimit() (int, error) {
|
||||
if s.Quota > 0 {
|
||||
return s.Quota
|
||||
return s.Quota, nil
|
||||
}
|
||||
return QuotaFromEnvironment()
|
||||
return quota.ReadLimit(s.Orm)
|
||||
}
|
||||
|
||||
func (s *Service) CreateBatch(request CreateBatchRequest) (BatchResponse, error) {
|
||||
@@ -71,12 +58,15 @@ func (s *Service) CreateBatch(request CreateBatchRequest) (BatchResponse, error)
|
||||
if err := s.Orm.Table("sense_devices").Where("status <> ?", "disabled").Count(&existingDevices).Error; err != nil {
|
||||
return BatchResponse{}, fmt.Errorf("count provisioned devices: %w", err)
|
||||
}
|
||||
quota := s.quota()
|
||||
available := quota - int(existingDevices)
|
||||
quotaLimit, err := s.quotaLimit()
|
||||
if err != nil {
|
||||
return BatchResponse{}, err
|
||||
}
|
||||
available := quotaLimit - int(existingDevices)
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
batch := Batch{ID: uuid.NewString(), IdempotencyKey: request.IdempotencyKey, Status: BatchReady, QuotaLimit: quota, ExistingCount: int(existingDevices), TotalCount: len(request.Rows)}
|
||||
batch := Batch{ID: uuid.NewString(), IdempotencyKey: request.IdempotencyKey, Status: BatchReady, QuotaLimit: quotaLimit, ExistingCount: int(existingDevices), TotalCount: len(request.Rows)}
|
||||
batch.CreateBy, batch.UpdateBy = request.CreateBy, request.CreateBy
|
||||
seenLines := map[int]bool{}
|
||||
seenAddresses := map[string]bool{}
|
||||
@@ -351,6 +341,10 @@ func safeFailure(err error) (string, string) {
|
||||
return "invalid_device", "设备信息或凭据不符合要求"
|
||||
case errors.Is(err, deviceService.ErrVersionConflict), errors.Is(err, admission.ErrConflict):
|
||||
return "version_conflict", "设备已被其他操作更新,请重试"
|
||||
case errors.Is(err, quota.ErrExceeded):
|
||||
return "quota_exceeded", "当前配额已满,请调整配额或停用其他设备后重试"
|
||||
case errors.Is(err, quota.ErrUnavailable):
|
||||
return "quota_unavailable", "配额配置不可读取,已拒绝新增或启用设备"
|
||||
default:
|
||||
return "activation_failed", "设备开通失败,请检查网络、地址和凭据后重试"
|
||||
}
|
||||
|
||||
@@ -153,17 +153,6 @@ func TestExecuteRequiresCredentialsWithoutCallingActivator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaFromEnvironment(t *testing.T) {
|
||||
t.Setenv("SENSE_PROVISIONING_QUOTA", "24")
|
||||
if got := QuotaFromEnvironment(); got != 24 {
|
||||
t.Fatalf("quota=%d", got)
|
||||
}
|
||||
t.Setenv("SENSE_PROVISIONING_QUOTA", "invalid")
|
||||
if got := QuotaFromEnvironment(); got != 16 {
|
||||
t.Fatalf("fallback quota=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCreateUsesOneIdempotentBatch(t *testing.T) {
|
||||
service := provisioningService(t, 16)
|
||||
sqlDB, err := service.Orm.DB()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
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 &Service{DB: base.Orm}, nil
|
||||
}
|
||||
|
||||
func (e *API) Get(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.Error(http.StatusBadRequest, err, "查询条件格式不正确")
|
||||
return
|
||||
}
|
||||
response, err := service.Overview(request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Update(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := UpdateRequest{UpdateBy: user.GetUserId(c)}
|
||||
if err = e.MakeContext(c).Bind(&request, binding.JSON).Errors; err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
response, err := service.Update(request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "配额已更新")
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalid):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrExceeded), errors.Is(err, ErrVersionConflict):
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
case errors.Is(err, ErrUnavailable):
|
||||
e.Error(http.StatusServiceUnavailable, err, err.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "容量与配额操作失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
ReadStatusReadable = "readable"
|
||||
ReadStatusUnreadable = "unreadable"
|
||||
)
|
||||
|
||||
type PageRequest struct {
|
||||
commonDto.Pagination `search:"-"`
|
||||
Keyword string `form:"keyword"`
|
||||
Status string `form:"status"`
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Limit int `json:"limit"`
|
||||
Reason string `json:"reason"`
|
||||
Version int64 `json:"version"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Limit int `json:"limit"`
|
||||
Used int `json:"used"`
|
||||
Remaining int `json:"remaining"`
|
||||
ReadStatus string `json:"readStatus"`
|
||||
Source string `json:"source"`
|
||||
Version int64 `json:"version"`
|
||||
LastReadAt time.Time `json:"lastReadAt"`
|
||||
LastChanged time.Time `json:"lastChangedAt,omitempty"`
|
||||
}
|
||||
|
||||
type DeviceOccupancy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Status string `json:"status"`
|
||||
Occupied bool `json:"occupied"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Tier struct {
|
||||
Limit int `json:"limit"`
|
||||
Configured bool `json:"configured"`
|
||||
Validation string `json:"validation"`
|
||||
DeliveryStatus string `json:"deliveryStatus"`
|
||||
}
|
||||
|
||||
type Overview struct {
|
||||
Summary Summary `json:"summary"`
|
||||
List []DeviceOccupancy `json:"list"`
|
||||
Count int64 `json:"count"`
|
||||
Tiers []Tier `json:"tiers"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
const SettingID uint = 1
|
||||
|
||||
type Setting struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement:false" json:"id"`
|
||||
Limit int `gorm:"not null" json:"limit"`
|
||||
Source string `gorm:"size:32;not null;default:'database'" json:"source"`
|
||||
Version int64 `gorm:"not null;default:1" json:"version"`
|
||||
common.ControlBy
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
func (Setting) TableName() string { return "sense_quota_settings" }
|
||||
|
||||
type Change struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
OldLimit int `gorm:"not null" json:"oldLimit"`
|
||||
NewLimit int `gorm:"not null" json:"newLimit"`
|
||||
Reason string `gorm:"size:512;not null" json:"reason"`
|
||||
ChangedBy int `gorm:"not null;index" json:"changedBy"`
|
||||
CreatedAt time.Time `gorm:"not null;index" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (Change) TableName() string { return "sense_quota_changes" }
|
||||
@@ -0,0 +1,168 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("配额配置不可读取")
|
||||
ErrExceeded = errors.New("当前配额已满")
|
||||
ErrInvalid = errors.New("配额配置不符合要求")
|
||||
ErrVersionConflict = errors.New("配额已被其他用户修改,请刷新后重试")
|
||||
)
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func (s Service) Overview(request PageRequest) (Overview, error) {
|
||||
if s.DB == nil {
|
||||
return Overview{}, ErrUnavailable
|
||||
}
|
||||
request.Keyword = strings.TrimSpace(request.Keyword)
|
||||
if request.Status != "" && request.Status != "active" && request.Status != "pending" && request.Status != "disabled" {
|
||||
return Overview{}, ErrInvalid
|
||||
}
|
||||
used, err := occupiedCount(s.DB)
|
||||
if err != nil {
|
||||
return Overview{}, fmt.Errorf("count quota occupancy: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
summary := Summary{Used: int(used), ReadStatus: ReadStatusUnreadable, LastReadAt: now}
|
||||
var setting Setting
|
||||
if err = s.DB.First(&setting, "id = ?", SettingID).Error; err == nil && validLimit(setting.Limit) {
|
||||
summary.Limit = setting.Limit
|
||||
summary.Remaining = setting.Limit - int(used)
|
||||
if summary.Remaining < 0 {
|
||||
summary.Remaining = 0
|
||||
}
|
||||
summary.ReadStatus = ReadStatusReadable
|
||||
summary.Source = setting.Source
|
||||
summary.Version = setting.Version
|
||||
summary.LastChanged = setting.UpdatedAt
|
||||
}
|
||||
|
||||
query := s.DB.Table("sense_devices").Select("id, name, location, status, version, updated_at")
|
||||
if request.Keyword != "" {
|
||||
pattern := "%" + strings.ToLower(request.Keyword) + "%"
|
||||
query = query.Where("LOWER(name) LIKE ? OR LOWER(location) LIKE ?", pattern, pattern)
|
||||
}
|
||||
if request.Status != "" {
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
var count int64
|
||||
if err = query.Count(&count).Error; err != nil {
|
||||
return Overview{}, fmt.Errorf("count quota devices: %w", err)
|
||||
}
|
||||
pageSize := request.GetPageSize()
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
var rows []DeviceOccupancy
|
||||
if err = query.Order("updated_at DESC").Limit(pageSize).Offset((request.GetPageIndex() - 1) * pageSize).Scan(&rows).Error; err != nil {
|
||||
return Overview{}, fmt.Errorf("list quota devices: %w", err)
|
||||
}
|
||||
for index := range rows {
|
||||
rows[index].Occupied = rows[index].Status != "disabled"
|
||||
}
|
||||
return Overview{Summary: summary, List: rows, Count: count, Tiers: deliveryTiers(summary.Limit)}, nil
|
||||
}
|
||||
|
||||
func (s Service) Update(request UpdateRequest) (Summary, error) {
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
if s.DB == nil || !validLimit(request.Limit) || request.Version < 1 || request.Reason == "" || len([]rune(request.Reason)) > 512 {
|
||||
return Summary{}, ErrInvalid
|
||||
}
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
setting, err := lockSetting(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if setting.Version != request.Version {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
result := tx.Model(&Setting{}).Where("id = ? AND version = ?", SettingID, request.Version).Updates(map[string]any{
|
||||
"limit": request.Limit, "source": "database", "version": request.Version + 1,
|
||||
"update_by": request.UpdateBy, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
return tx.Create(&Change{ID: uuid.NewString(), OldLimit: setting.Limit, NewLimit: request.Limit, Reason: request.Reason, ChangedBy: request.UpdateBy, CreatedAt: now}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
overview, err := s.Overview(PageRequest{})
|
||||
return overview.Summary, err
|
||||
}
|
||||
|
||||
func ReadLimit(db *gorm.DB) (int, error) {
|
||||
if db == nil {
|
||||
return 0, ErrUnavailable
|
||||
}
|
||||
var setting Setting
|
||||
if err := db.First(&setting, "id = ?", SettingID).Error; err != nil || !validLimit(setting.Limit) {
|
||||
return 0, ErrUnavailable
|
||||
}
|
||||
return setting.Limit, nil
|
||||
}
|
||||
|
||||
func WithAvailableSlot(db *gorm.DB, write func(*gorm.DB) error) error {
|
||||
if db == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
setting, err := lockSetting(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
used, err := occupiedCount(tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count quota occupancy: %w", err)
|
||||
}
|
||||
if used >= int64(setting.Limit) {
|
||||
return ErrExceeded
|
||||
}
|
||||
return write(tx)
|
||||
})
|
||||
}
|
||||
|
||||
func lockSetting(tx *gorm.DB) (Setting, error) {
|
||||
var setting Setting
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&setting, "id = ?", SettingID).Error
|
||||
if err != nil || !validLimit(setting.Limit) {
|
||||
return Setting{}, ErrUnavailable
|
||||
}
|
||||
return setting, nil
|
||||
}
|
||||
|
||||
func occupiedCount(db *gorm.DB) (int64, error) {
|
||||
var count int64
|
||||
err := db.Table("sense_devices").Where("status <> ?", "disabled").Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func validLimit(limit int) bool { return limit >= 1 && limit <= 100000 }
|
||||
|
||||
func deliveryTiers(configured int) []Tier {
|
||||
tiers := []Tier{
|
||||
{Limit: 16, Validation: "verified", DeliveryStatus: "默认学校试点交付档位"},
|
||||
{Limit: 32, Validation: "unverified", DeliveryStatus: "需完成目标硬件压测后启用"},
|
||||
{Limit: 64, Validation: "unverified", DeliveryStatus: "不作单机容量承诺"},
|
||||
{Limit: 128, Validation: "unverified", DeliveryStatus: "当前不在交付承诺范围"},
|
||||
}
|
||||
for index := range tiers {
|
||||
tiers[index].Configured = tiers[index].Limit == configured
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
)
|
||||
|
||||
func testQuotaDB(t *testing.T, limit int) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.AutoMigrate(&deviceModels.Device{}, &Setting{}, &Change{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&Setting{ID: SettingID, Limit: limit, Source: "test", Version: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestOverviewShowsOccupancyPaginationAndDeliveryBoundaries(t *testing.T) {
|
||||
db := testQuotaDB(t, 16)
|
||||
for index, status := range []string{"active", "pending", "disabled"} {
|
||||
device := deviceModels.Device{ID: uuid.NewString(), Name: "设备", Location: "位置", Modality: "video", CapabilitiesJSON: "[]", Status: status, AdapterStatus: "ready", Version: int64(index + 1)}
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
request := PageRequest{}
|
||||
request.PageIndex, request.PageSize = 1, 64
|
||||
response, err := (Service{DB: db}).Overview(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Summary.Limit != 16 || response.Summary.Used != 2 || response.Summary.Remaining != 14 || response.Count != 3 || len(response.List) != 3 {
|
||||
t.Fatalf("unexpected overview: %#v", response)
|
||||
}
|
||||
if response.Tiers[0].Validation != "verified" || response.Tiers[1].Validation != "unverified" || !response.Tiers[0].Configured {
|
||||
t.Fatalf("delivery tiers overpromise capacity: %#v", response.Tiers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreadableQuotaKeepsReadsAndRejectsWrites(t *testing.T) {
|
||||
db := testQuotaDB(t, 16)
|
||||
if err := db.Create(&deviceModels.Device{ID: uuid.NewString(), Name: "已有设备", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(&Setting{}, SettingID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := (Service{DB: db}).Overview(PageRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Summary.ReadStatus != ReadStatusUnreadable || response.Summary.Used != 1 || len(response.List) != 1 {
|
||||
t.Fatalf("read-only fallback failed: %#v", response)
|
||||
}
|
||||
if err = WithAvailableSlot(db, func(*gorm.DB) error { return nil }); err != ErrUnavailable {
|
||||
t.Fatalf("write with unreadable quota error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAllowsLowerLimitWithoutStoppingExistingDevices(t *testing.T) {
|
||||
db := testQuotaDB(t, 16)
|
||||
for index := 0; index < 2; index++ {
|
||||
if err := db.Create(&deviceModels.Device{ID: uuid.NewString(), Name: "设备", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
summary, err := (Service{DB: db}).Update(UpdateRequest{Limit: 1, Reason: "测试降低配额", Version: 1, UpdateBy: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.Limit != 1 || summary.Used != 2 || summary.Remaining != 0 {
|
||||
t.Fatalf("unexpected lowered quota: %#v", summary)
|
||||
}
|
||||
var devices int64
|
||||
if err = db.Model(&deviceModels.Device{}).Count(&devices).Error; err != nil || devices != 2 {
|
||||
t.Fatalf("existing devices changed: count=%d err=%v", devices, err)
|
||||
}
|
||||
var changes int64
|
||||
if err = db.Model(&Change{}).Count(&changes).Error; err != nil || changes != 1 {
|
||||
t.Fatalf("audit changes=%d err=%v", changes, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSlotReservationDoesNotExceedQuota(t *testing.T) {
|
||||
db := testQuotaDB(t, 4)
|
||||
const workers = 12
|
||||
var wait sync.WaitGroup
|
||||
var accepted int
|
||||
var lock sync.Mutex
|
||||
for index := 0; index < workers; index++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
err := WithAvailableSlot(db, func(tx *gorm.DB) error {
|
||||
return tx.Create(&deviceModels.Device{ID: uuid.NewString(), Name: "并发设备", Modality: "video", CapabilitiesJSON: "[]", Status: "pending", AdapterStatus: "ready", Version: 1}).Error
|
||||
})
|
||||
if err == nil {
|
||||
lock.Lock()
|
||||
accepted++
|
||||
lock.Unlock()
|
||||
} else if err != ErrExceeded {
|
||||
t.Errorf("unexpected reservation error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if accepted != 4 {
|
||||
t.Fatalf("accepted=%d want=4", accepted)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&deviceModels.Device{}).Where("status <> ?", "disabled").Count(&count).Error; err != nil || count != 4 {
|
||||
t.Fatalf("occupied=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
|
||||
"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), migrateSenseQuota)
|
||||
}
|
||||
|
||||
func migrateSenseQuota(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate("a.Setting{}, "a.Change{}); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
setting := quota.Setting{ID: quota.SettingID, Limit: initialQuotaLimit(), Source: "migration", Version: 1}
|
||||
setting.CreatedAt, setting.UpdatedAt = now, now
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&setting).Error; 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: "SenseQuota", Title: "容量与配额", Icon: "data-line", Path: "quota",
|
||||
Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:quota:list",
|
||||
ParentId: root.MenuId, Component: "/sense/quota/index", Sort: 8, Visible: "0", IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
update, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
|
||||
MenuName: "SenseQuotaUpdate", Title: "调整配额", MenuType: "F", Action: "PUT",
|
||||
Permission: "sense:quota:update", ParentId: page.MenuId,
|
||||
Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var devicePage migrationModels.SysMenu
|
||||
if err = tx.Where("menu_name = ?", "SenseDeviceManage").First(&devicePage).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
enable, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
|
||||
MenuName: "SenseDeviceEnable", Title: "启用设备", MenuType: "F", Action: "PUT",
|
||||
Permission: "sense:device:enable", ParentId: devicePage.MenuId,
|
||||
Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, devicePage.MenuId), Sort: 5, 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}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: "/api/v1/quota", V2: "GET"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = attachDeviceRole(tx, "site_admin", []migrationModels.SysMenu{update, enable}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range [][2]string{{"/api/v1/quota", "PUT"}, {"/api/v1/devices/:id/enable", "PUT"}} {
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: "site_admin", 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
|
||||
})
|
||||
}
|
||||
|
||||
func initialQuotaLimit() int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("SENSE_PROVISIONING_QUOTA")))
|
||||
if err != nil || value < 1 || value > 100000 {
|
||||
return 16
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -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/quota"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func prepareQuotaMigrationDB(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
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 := db.Create(&migrationModels.SysMenu{MenuName: "SenseDeviceManage", Title: "设备管理", Path: "devices", MenuType: "C", Component: "/sense/device/index"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertQuotaMigration(t *testing.T, db *gorm.DB, version string) {
|
||||
t.Helper()
|
||||
var setting quota.Setting
|
||||
if err := db.First(&setting, "id = ?", quota.SettingID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, reads, updates, enables, viewerWrites, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name IN ?", []string{"SenseQuota", "SenseQuotaUpdate", "SenseDeviceEnable"}).Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/quota", "GET").Count(&reads)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/quota", "PUT").Count(&updates)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/devices/:id/enable", "PUT").Count(&enables)
|
||||
db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v2 = ?", "viewer", "PUT").Count(&viewerWrites)
|
||||
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
|
||||
if setting.Limit != 16 || menus != 3 || reads != 3 || updates != 1 || enables != 1 || viewerWrites != 0 || applied != 1 {
|
||||
t.Fatalf("setting=%#v menus=%d reads=%d updates=%d enables=%d viewerWrites=%d applied=%d", setting, menus, reads, updates, enables, viewerWrites, applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaMigrationAddsDefaultAndLeastPrivilegeMenus(t *testing.T) {
|
||||
t.Setenv("SENSE_PROVISIONING_QUOTA", "")
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepareQuotaMigrationDB(t, db)
|
||||
const version = "2026082812000_quota.go"
|
||||
if err = migrateSenseQuota(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertQuotaMigration(t, db, version)
|
||||
}
|
||||
|
||||
func TestQuotaMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_QUOTA_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_QUOTA_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
|
||||
}
|
||||
t.Setenv("SENSE_PROVISIONING_QUOTA", "")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_quota_75_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 })
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepareQuotaMigrationDB(t, db)
|
||||
const version = "2026082812000_quota.go"
|
||||
if err = migrateSenseQuota(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertQuotaMigration(t, db, version)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ export function disableDevice(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}/disable`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function enableDevice(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}/enable`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function updateDeviceCredentials(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}/credentials`, method: 'put', data })
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getQuotaOverview(query) {
|
||||
return request({ url: '/api/v1/quota', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function updateQuota(data) {
|
||||
return request({ url: '/api/v1/quota', method: 'put', data })
|
||||
}
|
||||
@@ -69,6 +69,7 @@
|
||||
<el-button v-permisaction="['sense:device:credential']" type="primary" link size="small" :icon="Key" @click="handleCredential(scope.row)">更新凭据</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-if="scope.row.status !== 'disabled'" v-permisaction="['sense:device:disable']" type="danger" link size="small" @click="handleDisable(scope.row)">停用</el-button>
|
||||
<el-button v-else v-permisaction="['sense:device:enable']" type="success" link size="small" @click="handleEnable(scope.row)">启用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -141,6 +142,7 @@ import { Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
addDevice,
|
||||
disableDevice,
|
||||
enableDevice,
|
||||
getDevice,
|
||||
listDevices,
|
||||
updateDevice,
|
||||
@@ -284,6 +286,14 @@ export default {
|
||||
this.msgSuccess(response.msg)
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleEnable(row) {
|
||||
this.$confirm(`启用“${row.name}”会占用一路配额,并要求重新完成视频接入验证。是否继续?`, '启用设备', {
|
||||
confirmButtonText: '确认启用', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(() => enableDevice(row.id, disableDevicePayload(row.version))).then(response => {
|
||||
this.msgSuccess(response.msg)
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div><h3>边缘节点</h3><p>查看各现场节点的运行负载、连接状态和待回填数据。离线时保留最后一次已知状态。</p></div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新状态</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="summary.offline" type="warning" :closable="false" show-icon class="status-alert">
|
||||
<template #title>{{ summary.offline }} 个节点离线</template>
|
||||
离线阈值为 {{ summary.heartbeatTimeoutSeconds }} 秒。页面中的隧道、视频和回填数据是节点离线前的最后已知状态。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="边缘节点概览">
|
||||
<el-col v-for="item in summaryCards" :key="item.label" :xs="12" :sm="6">
|
||||
<div class="summary-item"><span>{{ item.label }}</span><strong :class="item.className">{{ item.value }}</strong></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="nodes" border stripe empty-text="暂无边缘节点">
|
||||
<el-table-column label="节点" min-width="180">
|
||||
<template #default="scope"><strong>{{ scope.row.name }}</strong><div class="muted">{{ scope.row.id }} · {{ scope.row.location || '未填写位置' }}</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="106" align="center"><template #default="scope"><el-tag :type="stateType(scope.row.status)">{{ stateLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="最近心跳" min-width="150"><template #default="scope"><div>{{ freshnessText(scope.row) }}</div><small>{{ formatTime(scope.row.lastHeartbeatAt) }}</small></template></el-table-column>
|
||||
<el-table-column label="负载" min-width="112"><template #default="scope">{{ scope.row.loadUsed }} / {{ scope.row.loadCapacity }} 路</template></el-table-column>
|
||||
<el-table-column label="控制隧道" min-width="142"><template #default="scope"><el-tag size="small" :type="stateType(scope.row.controlTunnelStatus)">{{ stateLabel(scope.row.controlTunnelStatus) }}</el-tag><div class="muted">{{ scope.row.controlLatencyMs ? `${scope.row.controlLatencyMs} ms` : scope.row.controlTunnelDetail }}</div></template></el-table-column>
|
||||
<el-table-column label="视频数据面" min-width="150"><template #default="scope"><el-tag size="small" :type="stateType(scope.row.videoPlaneStatus)">{{ stateLabel(scope.row.videoPlaneStatus) }}</el-tag><div class="muted">{{ scope.row.activeStreamCount }} 路视频 · {{ scope.row.videoPlaneDetail }}</div></template></el-table-column>
|
||||
<el-table-column label="回填队列" min-width="128"><template #default="scope"><el-tag size="small" :type="stateType(scope.row.backfillStatus)">{{ stateLabel(scope.row.backfillStatus) }}</el-tag><div class="muted">{{ scope.row.backfillQueueDepth }} 条待回填</div></template></el-table-column>
|
||||
<el-table-column label="版本" min-width="130"><template #default="scope"><div>{{ scope.row.runtimeVersion || '—' }}</div><small>{{ scope.row.modelVersion || '未部署模型' }}</small></template></el-table-column>
|
||||
<el-table-column label="操作" width="92" fixed="right"><template #default="scope"><el-button v-permisaction="['sense:edge-node:detail']" type="primary" link @click="openDetail(scope.row.id)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="边缘节点详情" width="min(800px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<template v-if="selected">
|
||||
<el-alert v-if="selected.stale" title="当前显示最后已知状态" type="warning" :closable="false" show-icon class="detail-alert">最近心跳距今 {{ durationText(selected.freshnessSeconds) }};连接恢复前,下列通道数据不会继续更新。</el-alert>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="节点">{{ selected.name }}({{ selected.id }})</el-descriptions-item><el-descriptions-item label="位置">{{ selected.location || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运行版本">{{ selected.runtimeVersion || '—' }}</el-descriptions-item><el-descriptions-item label="模型版本">{{ selected.modelVersion || '未部署' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运行时长">{{ durationText(selected.uptimeSeconds) }}</el-descriptions-item><el-descriptions-item label="最近心跳">{{ formatTime(selected.lastHeartbeatAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="控制隧道">{{ stateLabel(selected.controlTunnelStatus) }} · {{ selected.controlTunnelDetail }}</el-descriptions-item><el-descriptions-item label="视频数据面">{{ stateLabel(selected.videoPlaneStatus) }} · {{ selected.videoPlaneDetail }}</el-descriptions-item>
|
||||
<el-descriptions-item label="回填队列">{{ stateLabel(selected.backfillStatus) }} · {{ selected.backfillQueueDepth }} 条</el-descriptions-item><el-descriptions-item label="恢复阶段">{{ recoveryText(selected) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4 class="audit-title">状态审计</h4>
|
||||
<el-timeline v-if="selected.events?.length">
|
||||
<el-timeline-item v-for="event in selected.events" :key="event.id" :timestamp="formatTime(event.occurredAt)" placement="top"><strong>{{ eventLabel(event.eventType) }}</strong><div>{{ stateLabel(event.fromStatus) }} → {{ stateLabel(event.toStatus) }}</div><small>{{ event.detail }}</small></el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无状态变化记录" :image-size="72" />
|
||||
</template>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getEdgeNode, listEdgeNodes } from '@/api/sense/edge-node'
|
||||
import { buildEdgeNodeQuery, durationText, eventLabel, freshnessText, stateLabel, stateType } from './edgeNodeState'
|
||||
|
||||
defineOptions({ name: 'SenseEdgeNode' })
|
||||
const loading = ref(false)
|
||||
const nodes = ref([])
|
||||
const total = ref(0)
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10 })
|
||||
const summary = reactive({ total: 0, online: 0, offline: 0, recovering: 0, loadUsed: 0, loadCapacity: 0, backfillQueueDepth: 0, heartbeatTimeoutSeconds: 90 })
|
||||
const summaryCards = computed(() => [
|
||||
{ label: '节点总数', value: summary.total }, { label: '在线 / 恢复中', value: `${summary.online} / ${summary.recovering}`, className: 'online-number' },
|
||||
{ label: '总负载', value: `${summary.loadUsed} / ${summary.loadCapacity} 路` }, { label: '待回填', value: `${summary.backfillQueueDepth} 条`, className: summary.backfillQueueDepth ? 'warning-number' : '' }
|
||||
])
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function recoveryText(item) { return item.status === 'recovering' ? (item.recoveryPhase || '通道收敛中') : '已收敛' }
|
||||
async function load() { loading.value = true; try { const payload = unwrap(await listEdgeNodes(buildEdgeNodeQuery(query))) || {}; nodes.value = payload.list || []; total.value = payload.count || 0; Object.assign(summary, payload.summary || {}) } catch (error) { ElMessage.error(error.message || '边缘节点加载失败') } finally { loading.value = false } }
|
||||
async function openDetail(id) { try { selected.value = unwrap(await getEdgeNode(id)); detailOpen.value = true } catch (error) { ElMessage.error(error.message || '节点详情加载失败') } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.status-alert{margin:16px 0}.summary-row{margin:16px 0 20px}.summary-item{display:flex;align-items:center;justify-content:space-between;min-height:72px;padding:12px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span{color:#606266}.summary-item strong{font-size:20px}.online-number{color:#67c23a}.warning-number{color:#e6a23c}.muted,small{color:#909399;font-size:12px}.detail-alert{margin-bottom:16px}.audit-title{margin:20px 0 14px}@media(max-width:768px){.page-header{align-items:stretch;flex-direction:column}.summary-item{margin-bottom:8px;min-height:64px}.page-header .el-button{min-height:44px}}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>容量与配额</h3>
|
||||
<p>查看当前占用与安全闸状态。配额是交付配置,不等于单机性能承诺。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button v-permisaction="['sense:device:add']" :icon="Plus" :disabled="!gate.writable" @click="router.push('/sense/devices')">新增设备</el-button>
|
||||
<el-button
|
||||
v-permisaction="['sense:quota:update']"
|
||||
type="primary"
|
||||
:icon="Setting"
|
||||
:disabled="summary.readStatus !== 'readable'"
|
||||
@click="openQuotaDialog"
|
||||
>调整配额</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert :title="gate.title" :type="gate.type" :closable="false" show-icon class="gate-alert">
|
||||
{{ gate.detail }}
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="容量摘要">
|
||||
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>当前配置配额</span><strong>{{ readableLimit }}</strong><small>来源:{{ sourceLabel }}</small></div></el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>已占用设备</span><strong>{{ summary.used }} 路</strong><small>已接入和待接入均占用</small></div></el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>剩余可用</span><strong>{{ readableRemaining }}</strong><small>写入时再次原子校验</small></div></el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6"><div class="summary-item"><span>配额读取状态</span><strong class="read-state"><el-tag :type="summary.readStatus === 'readable' ? 'success' : 'danger'">{{ summary.readStatus === 'readable' ? '可读取' : '读取失败' }}</el-tag></strong><small>最后读取:{{ formatTime(summary.lastReadAt) }}</small></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<section class="usage-section">
|
||||
<div class="section-header"><h4>占用情况</h4><span>{{ usageCaption }}</span></div>
|
||||
<el-progress :percentage="percent" :status="gate.type === 'error' ? 'exception' : gate.type === 'warning' ? 'warning' : ''" />
|
||||
</section>
|
||||
|
||||
<div class="section-header"><div><h4>设备占用明细</h4><p>停用设备不占用配额;重新启用时会执行安全闸校验。</p></div></div>
|
||||
<el-form :model="query" class="filter-form" label-width="76px" @submit.prevent="search">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" clearable placeholder="全部状态">
|
||||
<el-option label="已接入" value="active" /><el-option label="待接入" value="pending" /><el-option label="已停用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备名称"><el-input v-model="query.keyword" clearable placeholder="请输入设备名称或位置" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item class="filter-actions"><el-button type="primary" :icon="Search" native-type="submit">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table v-loading="loading" :data="devices" border stripe empty-text="当前没有设备">
|
||||
<el-table-column prop="name" label="设备名称" min-width="160" />
|
||||
<el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="接入状态" width="110" align="center"><template #default="scope"><el-tag :type="scope.row.status === 'active' ? 'success' : scope.row.status === 'disabled' ? 'info' : 'warning'">{{ deviceStatusLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="配额占用" width="110" align="center"><template #default="scope">{{ scope.row.occupied ? '占用 1 路' : '不占用' }}</template></el-table-column>
|
||||
<el-table-column label="更新时间" width="180" align="center"><template #default="scope">{{ formatTime(scope.row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'disabled'"
|
||||
v-permisaction="['sense:device:enable']"
|
||||
type="success"
|
||||
link
|
||||
:disabled="!gate.writable"
|
||||
@click="enable(scope.row)"
|
||||
>启用</el-button>
|
||||
<el-button v-else type="primary" link @click="router.push('/sense/devices')">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
|
||||
<div class="section-header tier-header"><div><h4>交付档位与验证状态</h4><p>“未验证”不代表系统承诺可稳定承载,必须先完成目标硬件性能测试。</p></div></div>
|
||||
<el-table :data="tiers" border>
|
||||
<el-table-column label="档位" width="100"><template #default="scope">{{ scope.row.limit }} 路</template></el-table-column>
|
||||
<el-table-column label="当前配置" width="120"><template #default="scope"><el-tag v-if="scope.row.configured" type="success">当前配置</el-tag><span v-else>未配置</span></template></el-table-column>
|
||||
<el-table-column label="性能测试" width="120"><template #default="scope"><el-tag :type="scope.row.validation === 'verified' ? 'success' : 'warning'">{{ tierValidationLabel(scope.row.validation) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="deliveryStatus" label="说明" min-width="220" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogOpen" title="调整配额" width="min(520px, calc(100vw - 24px))" :close-on-click-modal="false" @closed="resetDialog">
|
||||
<el-alert title="调整前请确认目标硬件已经完成容量测试" type="warning" :closable="false" show-icon class="dialog-alert">
|
||||
降低到当前占用以下不会中断已有视频,但会阻止新增与启用,直到占用回到配额内。
|
||||
</el-alert>
|
||||
<el-form ref="quotaFormRef" :model="quotaForm" :rules="quotaRules" label-width="110px">
|
||||
<el-form-item label="新配额(路)" prop="limit"><el-input-number v-model="quotaForm.limit" :min="1" :max="100000" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="变更原因" prop="reason"><el-input v-model.trim="quotaForm.reason" maxlength="512" show-word-limit placeholder="请输入变更原因" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveQuota">确认调整</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, RefreshLeft, Search, Setting } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getQuotaOverview, updateQuota } from '@/api/sense/quota'
|
||||
import { enableDevice } from '@/api/sense/device'
|
||||
import { buildQuotaQuery, deviceStatusLabel, quotaGate, tierValidationLabel, usagePercent } from './quotaState'
|
||||
|
||||
defineOptions({ name: 'SenseQuota' })
|
||||
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const saving = ref(false)
|
||||
const devices = ref([])
|
||||
const tiers = ref([])
|
||||
const total = ref(0)
|
||||
const summary = reactive({ limit: 0, used: 0, remaining: 0, readStatus: 'unreadable', source: '', version: 0 })
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10, keyword: '', status: '' })
|
||||
const dialogOpen = ref(false)
|
||||
const quotaFormRef = ref()
|
||||
const quotaForm = reactive({ limit: 16, reason: '' })
|
||||
const quotaRules = {
|
||||
limit: [{ required: true, type: 'number', min: 1, max: 100000, message: '请输入 1 至 100000 的整数配额', trigger: 'change' }],
|
||||
reason: [{ required: true, message: '请输入变更原因,以便审计追溯', trigger: 'blur' }]
|
||||
}
|
||||
const gate = computed(() => quotaGate(summary))
|
||||
const percent = computed(() => usagePercent(summary))
|
||||
const readableLimit = computed(() => summary.readStatus === 'readable' ? `${summary.limit} 路` : '—')
|
||||
const readableRemaining = computed(() => summary.readStatus === 'readable' ? `${summary.remaining} 路` : '—')
|
||||
const sourceLabel = computed(() => summary.readStatus === 'readable' ? (summary.source === 'migration' ? '初始化配置' : '数据库配置') : '不可读取')
|
||||
const usageCaption = computed(() => summary.readStatus === 'readable' ? `已占用 ${summary.used} / ${summary.limit} 路(${percent.value}%)` : `已占用 ${summary.used} 路;配额上限不可读取`)
|
||||
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await getQuotaOverview(buildQuotaQuery(query))) || {}
|
||||
Object.assign(summary, payload.summary || {})
|
||||
devices.value = payload.list || []
|
||||
tiers.value = payload.tiers || []
|
||||
total.value = payload.count || 0
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '容量与配额加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function search() { query.pageIndex = 1; load() }
|
||||
function reset() { Object.assign(query, { pageIndex: 1, keyword: '', status: '' }); load() }
|
||||
function openQuotaDialog() { quotaForm.limit = summary.limit; quotaForm.reason = ''; dialogOpen.value = true }
|
||||
function resetDialog() { quotaForm.reason = ''; quotaFormRef.value?.clearValidate() }
|
||||
async function enable(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`启用“${row.name}”会占用一路配额,并要求重新完成视频接入验证。是否继续?`, '启用设备', {
|
||||
type: 'warning', confirmButtonText: '确认启用', cancelButtonText: '取消'
|
||||
})
|
||||
await enableDevice(row.id, { version: row.version })
|
||||
ElMessage.success('设备已启用,请重新完成视频接入验证')
|
||||
await load()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '设备启用失败')
|
||||
}
|
||||
}
|
||||
async function saveQuota() {
|
||||
if (!await quotaFormRef.value.validate().catch(() => false)) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateQuota({ limit: quotaForm.limit, reason: quotaForm.reason, version: summary.version })
|
||||
ElMessage.success('配额已更新')
|
||||
dialogOpen.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.warning(error.message || '配额更新失败,请刷新后重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,.section-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3,.section-header h4{margin:0 0 6px}.page-header p,.section-header p{margin:0;color:#909399}.header-actions{display:flex;gap:8px;flex-wrap:wrap}.gate-alert{margin:16px 0}.summary-row{margin-bottom:18px}.summary-item{display:flex;flex-direction:column;min-height:108px;padding:14px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span,.summary-item small{color:#606266}.summary-item strong{margin:8px 0 4px;font-size:24px}.summary-item .read-state{font-size:16px}.usage-section{margin:4px 0 22px;padding:16px;border:1px solid #ebeef5}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin:16px 0 2px}.filter-form .el-form-item{width:250px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select){width:100%}.tier-header{margin-top:24px}.dialog-alert{margin-bottom:18px}@media(max-width:768px){.page-header{align-items:stretch;flex-direction:column}.filter-form .el-form-item{width:100%}.summary-item{margin-bottom:8px}}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
export function buildQuotaQuery(query) {
|
||||
return {
|
||||
pageIndex: Math.max(1, Number(query.pageIndex) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(query.pageSize) || 10)),
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
status: String(query.status || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function quotaGate(summary = {}) {
|
||||
if (summary.readStatus !== 'readable') {
|
||||
return {
|
||||
type: 'error',
|
||||
title: '配额配置不可读取',
|
||||
detail: '为避免超配额,新增和启用请求已关闭;已有设备、视频流与查询不受影响。请检查配置来源后重试。',
|
||||
writable: false
|
||||
}
|
||||
}
|
||||
if ((Number(summary.remaining) || 0) <= 0) {
|
||||
return {
|
||||
type: 'warning',
|
||||
title: '当前配额已满',
|
||||
detail: '新增和启用请求将被拒绝;已有设备、视频流与查询继续正常运行。',
|
||||
writable: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'success',
|
||||
title: '安全闸正常',
|
||||
detail: '新增和启用设备可继续;系统会在写入时再次原子校验剩余配额。',
|
||||
writable: true
|
||||
}
|
||||
}
|
||||
|
||||
export function usagePercent(summary = {}) {
|
||||
const limit = Number(summary.limit) || 0
|
||||
const used = Number(summary.used) || 0
|
||||
if (limit <= 0) return 0
|
||||
return Math.min(100, Math.max(0, Math.round((used / limit) * 100)))
|
||||
}
|
||||
|
||||
export function deviceStatusLabel(status) {
|
||||
return ({ active: '已接入', pending: '待接入', disabled: '已停用' })[status] || status || '未知'
|
||||
}
|
||||
|
||||
export function tierValidationLabel(value) {
|
||||
return value === 'verified' ? '已验证' : '未验证'
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { buildQuotaQuery, quotaGate, usagePercent } from '@/views/sense/quota/quotaState'
|
||||
|
||||
describe('Sense quota state', () => {
|
||||
test('keeps pagination independent from the configured quota', () => {
|
||||
expect(buildQuotaQuery({ pageIndex: 2, pageSize: 64, keyword: ' 东门 ', status: 'active' })).toEqual({
|
||||
pageIndex: 2, pageSize: 64, keyword: '东门', status: 'active'
|
||||
})
|
||||
})
|
||||
|
||||
test('closes writes when full or unreadable while preserving an actionable explanation', () => {
|
||||
expect(quotaGate({ readStatus: 'readable', remaining: 4 }).writable).toBe(true)
|
||||
expect(quotaGate({ readStatus: 'readable', remaining: 0 })).toMatchObject({ writable: false, title: '当前配额已满' })
|
||||
expect(quotaGate({ readStatus: 'unreadable', remaining: 4 })).toMatchObject({ writable: false, title: '配额配置不可读取' })
|
||||
})
|
||||
|
||||
test('bounds the occupancy percentage without treating quota as a list limit', () => {
|
||||
expect(usagePercent({ used: 12, limit: 16 })).toBe(75)
|
||||
expect(usagePercent({ used: 20, limit: 16 })).toBe(100)
|
||||
expect(usagePercent({ used: 12, limit: 0 })).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -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: 3c95336ab96aa1e245f67c598299f2904f0e4437
|
||||
synchronized_at: 2026-08-27T11:07:58Z
|
||||
wiki_revision: 5c2b5df080b160c1174be3e935fffba420885bff
|
||||
synchronized_at: 2026-08-28T06:14:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -174,3 +174,54 @@ Bell 使用独立 PostgreSQL、JWT realm、token key 和首次迁移管理员环
|
||||
- `dev_scripts/harness.py sync` 只从 Gitea Wiki 导出 `wiki-docs.json` 映射的核心镜像;`sync --check` 只检查一致性。
|
||||
- `archive`、`export` 和 `export --all` 只在人工明确要求时处理可选任务快照,任务快照不进入核心映射。
|
||||
- `dev_scripts/wiki_docs.py` 负责 Gitea Wiki 读取、revision、镜像头、脏文件保护和安全路径校验。
|
||||
|
||||
<!-- sense-provisioning:start -->
|
||||
## Sense 批量开通代码入口
|
||||
|
||||
工单 #72 的领域代码位于 `Sense/server/app/sense/provisioning/`,GoAdmin JWT/Casbin/PermissionAction 路由注册在 `Sense/server/app/admin/router/sense_provisioning.go`,迁移位于 `Sense/server/cmd/migrate/migration/version/2026082809000_provisioning.go`。前端页面为 `Sense/ui/src/views/sense/provisioning/index.vue`,API 封装为 `Sense/ui/src/api/sense/provisioning.js`;页面复用 BasicLayout、动态菜单、Axios、Element Plus Steps/Upload/Form/Table/Pagination/Dialog/Tag/Alert/Button 和权限指令。
|
||||
|
||||
PostgreSQL 表 `sense_provisioning_batches` 保存幂等键、配额快照和汇总状态,`sense_provisioning_items` 保存行号、设备信息、逐项状态、失败原因、尝试次数和已建立的设备引用;两表都没有凭据字段。执行链按条目条件领取 ready/failed 状态,调用既有 Device、Credential Vault 与 Admission 服务,成功项不整体回滚。API 根路径为 `/api/v1/provisioning/batches`,覆盖创建/预校验、列表、详情、执行、失败项重试、单项重试和无秘密 CSV 导出。
|
||||
<!-- sense-provisioning:end -->
|
||||
|
||||
<!-- sense-local-events:start -->
|
||||
## Sense 本地事件只读链路
|
||||
|
||||
- 内部模型、DTO、服务、API 与合成夹具:`Sense/server/app/sense/local_event/`。
|
||||
- GoAdmin 路由:`Sense/server/app/admin/router/sense_local_event.go`,仅开放 `GET /api/v1/local-events` 和 `GET /api/v1/local-events/:id`。
|
||||
- PostgreSQL 表:`sense_local_event_candidates`;迁移同时建立“本地事件”菜单、详情功能权限和 implementation_operator/site_admin/viewer 的只读 Casbin 策略。
|
||||
- go-admin-ui 页面:`Sense/ui/src/views/sense/local-event/index.vue`;复用 BasicLayout、Element Plus 表单、表格、分页、Dialog、Tag、权限指令与 Axios 封装。
|
||||
- API 响应只包含 Sense 内部候选、匿名源引用、规则引用、证据状态和保留信息,不包含 Bell/Brain schema 或送达字段。页面上的 Bell 送达状态固定解释为“不适用”,避免把本地候选冒充外部预警。
|
||||
- 列表和详情成功/失败读取会同步写入脱敏的 `sys_opera_log`,不依赖可关闭的全局数据库日志开关;记录只含动作、路由、操作者、结果和候选 ID,不保存筛选值、响应或证据内容。拒绝访问继续由认证/RBAC 身份审计记录。
|
||||
<!-- sense-local-events:end -->
|
||||
|
||||
<!-- sense-operations:start -->
|
||||
## Sense 运维中心代码路径
|
||||
|
||||
- 后端入口:`Sense/server/app/admin/router/sense_operations.go`。
|
||||
- 领域投影:`Sense/server/app/sense/operations/`,只读聚合 `sense_devices`、`sense_admission_results` 和 `sense_media_routes`,不建立第二套运维状态表。
|
||||
- API:`GET /api/v1/operations`、`GET /api/v1/operations/:id`、`POST /api/v1/operations/:id/retry`;均复用 GoAdmin JWT、Casbin、PermissionAction 和响应封装。
|
||||
- 设备重试先用版本 CAS 取得在途所有权,再调用既有 admission Probe;失败时释放在途闸。媒体重试以 CAS 写入 `retry_pending` 和到期时间,由既有 MediaMTX 对账循环执行。
|
||||
- 前端:`Sense/ui/src/views/sense/operations/` 与 `Sense/ui/src/api/sense/operations.js`,复用 BasicLayout、Element Plus 表单、表格、分页、Dialog、Tag 和权限按钮。
|
||||
- 本模块不导入 Brain/Bell 模型,不访问其数据库,不定义共享契约。
|
||||
<!-- sense-operations:end -->
|
||||
|
||||
<!-- sense-quota:start -->
|
||||
## Sense 容量与配额代码路径
|
||||
|
||||
- 领域模型、DTO、服务、API 与测试:`Sense/server/app/sense/quota/`。
|
||||
- GoAdmin 路由:`Sense/server/app/admin/router/sense_quota.go`;API 为 `GET /api/v1/quota` 和 `PUT /api/v1/quota`,复用 JWT、Casbin、PermissionAction 和 GoAdmin 响应封装。
|
||||
- PostgreSQL 表:`sense_quota_settings` 保存单行配置与版本,`sense_quota_changes` 保存不可变变更记录。迁移 `2026082812000_quota.go` 初始化默认 16、动态菜单、最小权限及设备启用权限。
|
||||
- 设备新增和重新启用由 `quota.WithAvailableSlot` 在事务内锁定配置行、计算非停用设备占用并执行写入;设备服务与批量开通服务共用该事实源。
|
||||
- 前端:`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-quota:end -->
|
||||
|
||||
<!-- sense-edge-nodes:start -->
|
||||
## 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,也不定义跨项目契约。
|
||||
<!-- sense-edge-nodes:end -->
|
||||
|
||||
@@ -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: 96fca4d2aa411725282bd3911c133efc75f7146a
|
||||
synchronized_at: 2026-08-27T09:04:59Z
|
||||
wiki_revision: 8f81b925af671209341f1282e128c7f048bca8ec
|
||||
synchronized_at: 2026-08-28T06:14:44Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -154,3 +154,48 @@ synchronized_at: 2026-08-27T09:04:59Z
|
||||
- Gitea Wiki 只保存长期有效的项目事实;核心页面由 `wiki-docs.json` 显式映射。
|
||||
- `docs/task/` 是按人工明确要求形成的专项或历史兼容快照,可能不完整或不是最新状态,不得替代工单。
|
||||
- 默认不创建、导出或更新任务快照;导出过程不自动删除本地历史文件。
|
||||
|
||||
<!-- sense-local-events:start -->
|
||||
## Sense 本地事件候选规则
|
||||
|
||||
- “本地事件候选”是 Sense 内部匿名记录,不是 Bell Event 或 Alert;任何跨项目输出必须等待版本化协调契约。
|
||||
- 候选状态与证据状态是两组独立状态,不能用证据成功推断事件已确认,也不能用事件已确认推断证据成功。
|
||||
- 列表和详情只读;新增、确认、删除、重试或送达不属于本页面。
|
||||
- 每条记录保存明确的 `retain_until`,页面展示实际到期时间;保留时长由服务端生产者/策略决定,页面不虚构全局固定天数。
|
||||
- 合成夹具只由测试显式装载,生产启动和迁移都不会自动写入假事件。
|
||||
- GET 列表和详情继续经过 Sense JWT、Casbin RBAC、数据权限中间件及系统操作审计。
|
||||
<!-- sense-local-events:end -->
|
||||
|
||||
<!-- sense-operations:start -->
|
||||
## Sense 运维状态规则
|
||||
|
||||
- 运维问题是设备、媒体或本地推理适配器的本地状态,不是 Event 或 Bell Alert;不得进入 Bell 业务预警队列。
|
||||
- 问题必须同时保留期望态、实际态、差异、建议动作、对象版本和安全闸;退避问题还要展示下次重试时间。
|
||||
- 设备认证失败和时间漂移复用既有视频接入探测;媒体失败复用既有 MediaMTX 对账循环。
|
||||
- 受控重试必须校验对象版本并防止同一对象并发执行;实施/运维和站点管理员可执行,viewer 只读。
|
||||
- 孤儿媒体路由只能显示“隔离待确认”,不得从运维中心自动删除。
|
||||
- Brain 未配置或未安装时显示本地推理 unavailable,不读取 Brain 数据库,也不阻断设备/媒体运维。
|
||||
<!-- sense-operations:end -->
|
||||
|
||||
<!-- sense-quota:start -->
|
||||
## Sense 容量与配额规则
|
||||
|
||||
- 配额是 Sense 单产品的交付配置,不是单机性能承诺;默认初始化为 16 路,数据库、循环、分页和列表容量不得以 16 为硬上限。
|
||||
- 配额占用按非停用设备计算:已接入和待接入各占用一路,停用设备不占用;重新启用必须重新经过配额安全闸并回到待接入状态。
|
||||
- 设备新增、重新启用及批量开通写入必须使用同一 PostgreSQL 配额事实源。写事务锁定单行配额配置、读取当前占用、检查剩余量后再写设备,防止并发超配。
|
||||
- 配额缺失、非法或读取失败时,新增、启用和批量相关写入必须拒绝;已有设备、视频流和只读查询继续可用。
|
||||
- 降低配额到当前占用以下不会自动停用设备或中断视频;剩余量按 0 显示,后续新增/启用持续拒绝,直到占用回到配额内。
|
||||
- 32/64/128 只展示当前配置和目标硬件性能测试状态。未验证不得解释为支持或稳定承载承诺。
|
||||
- 配额调整必须记录旧值、新值、操作人、时间和原因;viewer 与实施/运维角色只读,只有站点管理员可调整配额和重新启用设备。
|
||||
<!-- sense-quota:end -->
|
||||
|
||||
<!-- sense-edge-nodes:start -->
|
||||
## Sense 边缘节点状态规则
|
||||
|
||||
- **边缘节点投影**:Sense 保存的节点最后已知运行状态,不是 Brain/Bell 的共享机器身份或跨产品事实源。
|
||||
- **在线**:最近心跳距读取时刻不超过 90 秒,且没有未完成的恢复阶段。
|
||||
- **离线**:最近心跳距读取时刻超过 90 秒。离线是读取时推导状态,不得用“未知”覆盖最后已知的控制隧道、视频数据面、负载和回填队列;所有这些值必须同时标明陈旧。
|
||||
- **恢复中**:心跳已经恢复,但控制隧道、视频数据面或回填通道仍在独立收敛。只有恢复阶段明确收敛后才显示在线。
|
||||
- 状态转换写入不可变事件;列表和详情读取写入脱敏操作审计,不记录凭据或原始心跳载荷。
|
||||
- 合成夹具必须显式调用,生产迁移和启动不得自动写入节点。
|
||||
<!-- sense-edge-nodes:end -->
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -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-supervisor:end -->
|
||||
|
||||
<!-- sense-local-events:start -->
|
||||
### 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-local-events:end -->
|
||||
|
||||
<!-- sense-operations:start -->
|
||||
### 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-operations:end -->
|
||||
|
||||
<!-- sense-edge-nodes:start -->
|
||||
### 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 连接时必须记录迁移真库测试未执行。
|
||||
<!-- sense-edge-nodes:end -->
|
||||
|
||||
@@ -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: e912a1ca1410e01680a0f11f6199ccb42cd8fe8f
|
||||
synchronized_at: 2026-08-27T09:06:08Z
|
||||
wiki_revision: dcbdcf563017a1749fa76ad5f78c74a2c3cc6be1
|
||||
synchronized_at: 2026-08-28T06:16:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求
|
||||
@@ -190,3 +190,45 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险
|
||||
- Sense 账号不能登录 Bell,Bell 账号不能登录 Sense。
|
||||
- 三个项目独立构建、测试和版本化;Sense/Bell 独立迁移、备份、恢复和打包。
|
||||
- 集成断开不阻断各自核心能力,恢复后按 Outbox 和幂等收据继续。
|
||||
|
||||
<!-- sense-provisioning:start -->
|
||||
## Sense 批量开通长期规则
|
||||
|
||||
SEN-004 已在工单 #72 实现、合入 `dev` 并通过用户验收。Sense 可独立导入 CSV/XLSX 摄像头清单,服务端预校验地址、重复项和剩余配额,再按条目执行接入。批次保留创建时的配额与占用快照,但新的批次与设备写入必须读取 SEN-011 的统一配额事实源;16 不是数据库、循环、分页或单机容量硬上限。
|
||||
|
||||
导入清单禁止账号和密码字段。ONVIF/RTSP 凭据只在受控表单与单次执行请求中短暂存在,随后进入既有加密 Vault;批次、条目、响应、日志和结果导出都不得包含凭据。批次允许部分成功,成功设备不因其他条目失败而回滚;批量重试与单项重试只领取失败条目,并通过批次幂等键、持久化设备引用和条件状态更新避免重复创建设备。
|
||||
<!-- sense-provisioning:end -->
|
||||
|
||||
<!-- sense-local-events:start -->
|
||||
## Sense 本地事件候选
|
||||
|
||||
SEN-008 在 Sense 内提供只读的本地事件候选查询。网管或非技术人员可按发生时间、候选状态、证据状态、规则引用和关键词筛选,查看候选详情、证据处理结果与逐条保留到期时间。
|
||||
|
||||
候选状态只表示 Sense 内部的 `candidate`(候选)或 `confirmed`(已确认事件);证据状态独立表示 `pending`(处理中)、`success`(成功)或 `failed`(失败)。本能力在 Brain、Bell 均未启动时仍可使用。本地候选不是跨项目事件契约,不等同于 Bell Alert,也不表示已经向 Bell 送达。
|
||||
<!-- sense-local-events:end -->
|
||||
|
||||
<!-- sense-operations:start -->
|
||||
## Sense 运维中心
|
||||
|
||||
SEN-009 由 Sense 独立提供设备、媒体和可选本地推理的运维概览。页面以“期望态—实际态—未收敛差异—下一步动作”展示认证失败、退避等待、时间漂移、孤儿安全闸和能力未安装,面向网管或非技术运维人员,不暴露凭据或内部数据库结构。
|
||||
|
||||
运维中心在 Brain、Bell 均未启动时可用。本地推理适配器尚未建立协调契约时显示 unavailable;该状态不导致页面失败。所有问题均为 Sense 运维事实,不等同于 Bell 业务 Alert,也不会从本页面发送给 Bell。
|
||||
|
||||
读取默认只读。受控重试仅对明确可重试的设备/媒体问题开放,必须通过 RBAC、对象版本和在途状态校验并写入脱敏审计。孤儿媒体资源只隔离和提示,不提供自动删除。
|
||||
<!-- sense-operations:end -->
|
||||
|
||||
<!-- sense-quota:start -->
|
||||
## Sense 容量与配额长期规则
|
||||
|
||||
SEN-011 将默认 16 路实现为可配置的 Sense 本地交付配额。统一配置保存在 PostgreSQL;新增设备、重新启用和批量开通在写事务内原子校验。配额不可读时拒绝这些写入,但已有设备、视频流和读取保持可用。降低配额不会自动停用现有设备。
|
||||
|
||||
容量页面只展示 16/32/64/128 的当前配置和性能测试状态。除已经验证的 16 路学校试点基线外,其余档位在完成目标硬件压测前都不得解释为单机承载承诺。
|
||||
<!-- sense-quota:end -->
|
||||
|
||||
<!-- sense-edge-nodes:start -->
|
||||
## Sense 边缘节点资产与离线状态
|
||||
|
||||
Sense 为网管和非技术运维人员提供只读的“边缘节点”页面,集中查看节点名称、位置、运行版本、模型版本、运行时长、承载路数、控制隧道、视频数据面和回填队列。节点最近心跳超过 90 秒即在读取时显示为离线;离线不会清空最后一次采集到的通道和队列状态,页面必须同时标明陈旧时长。心跳恢复后,连接状态与控制、视频、回填通道分别收敛,未全部收敛时显示“恢复中”。列表、详情及状态变化均可审计。
|
||||
|
||||
本能力只管理 Sense 自有投影,不建立 Brain/Bell 共享身份、控制协议或跨项目回填执行;Brain、Bell 未运行时仍可独立查看。合成节点仅供显式开发和测试,不由生产启动或迁移自动写入。
|
||||
<!-- sense-edge-nodes:end -->
|
||||
|
||||
@@ -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: 367c6aed6eaeece793622431c7d1b11b1b9dbec5
|
||||
synchronized_at: 2026-08-27T09:07:20Z
|
||||
wiki_revision: 34b85f1c3fc2c3e0e7fa0de3df056940fcb71333
|
||||
synchronized_at: 2026-08-28T06:17:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# YoVision 部署与运维
|
||||
@@ -80,3 +80,23 @@ Sense\start_sense.bat
|
||||
- MediaMTX 启用时进程、路径和播放链路状态可定位。
|
||||
- Supervisor 不会与手工进程重复占用端口。
|
||||
- 日志和文档没有秘密;未验证的 Brain、Bell、真机或生产行为明确标注。
|
||||
|
||||
<!-- sense-provisioning:start -->
|
||||
## Sense 批量开通与容量配额配置排错
|
||||
|
||||
数据库迁移 `2026082812000_quota.go` 首次创建统一配额配置:读取当次迁移进程的可选 `SENSE_PROVISIONING_QUOTA` 正整数作为初值,未设置或无效时使用 16。迁移完成后,运行期配额以 PostgreSQL `sense_quota_settings` 为事实源,并由“容量与配额”页面受控调整;后续修改环境变量不会覆盖数据库值。每个批量开通批次仍记录创建时的配额与占用快照。
|
||||
|
||||
升级后看不到“容量与配额”时,确认迁移成功并重新登录刷新动态菜单。implementation_operator、site_admin、viewer 可读取容量;只有 site_admin 可调整配额和重新启用设备。调整必须填写原因。降低配额不会停止已有流;当占用达到或超过配额时,新增和启用返回冲突。
|
||||
|
||||
页面显示“配额配置不可读取”时,先确认 `sense_quota_settings` 的 ID 1 记录存在、limit 为 1–100000 的整数且数据库可读;不要通过手工插入设备绕过安全闸。配置不可读时已有流和查询应继续,新设备、重新启用和批量开通写入会返回服务不可用。32/64/128 的“未验证”状态不能作为容量承诺。
|
||||
|
||||
批量导入被拒绝时还应检查模板只含 line_number/name/location/address,地址必须为不带账号、查询参数或片段的 HTTP(S) ONVIF 地址。条目失败时按页面原因检查网络、获准网段、凭据和接入状态;仅重试失败项,不删除已成功设备。日志、导出和问题记录不得粘贴摄像头凭据。
|
||||
<!-- sense-provisioning:end -->
|
||||
|
||||
<!-- sense-edge-nodes:start -->
|
||||
## Sense 边缘节点状态排错
|
||||
|
||||
升级后看不到“边缘节点”菜单时,先确认数据库迁移 `2026082813000_edge_node.go` 已成功,再重新登录或刷新动态菜单。implementation_operator、site_admin、viewer 均只有列表和详情读取权限,本模块没有网页写入、删除或远程控制接口。
|
||||
|
||||
节点超过 90 秒没有心跳即显示离线。离线时页面继续显示最后已知的隧道、视频、负载和回填状态,并标明陈旧时长,这不表示通道仍实时可用。心跳恢复但通道未收敛时显示“恢复中”,应分别检查控制隧道、视频数据面和回填队列。当前版本只提供 Sense 内部投影入口,不包含外部心跳接入协议或跨节点回填执行;没有节点数据时不会自动生成演示节点。
|
||||
<!-- sense-edge-nodes:end -->
|
||||
|
||||
Reference in New Issue
Block a user