Refactor config management flow and admin IA
This commit is contained in:
parent
33d5f6f8ab
commit
328705ebc6
@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"3588AdminBackend/internal/api"
|
||||
"3588AdminBackend/internal/config"
|
||||
@ -39,6 +40,11 @@ func main() {
|
||||
defer store.Close()
|
||||
taskRepo := storage.NewTasksRepo(store.DB())
|
||||
assetsRepo := storage.NewAssetsRepo(store.DB())
|
||||
if imported, err := service.ImportStandardTemplatesFromDir(assetsRepo, filepath.Join("templates", "standard_templates")); err != nil {
|
||||
log.Fatalf("import standard templates: %v", err)
|
||||
} else if imported > 0 {
|
||||
log.Printf("imported %d standard templates", imported)
|
||||
}
|
||||
stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
|
||||
auditRepo := storage.NewAuditLogsRepo(store.DB())
|
||||
taskSvc := service.NewTaskService(cfg, agentClient, regSvc, taskRepo)
|
||||
|
||||
@ -17,7 +17,7 @@ type Config struct {
|
||||
DataDir string `json:"data_dir,omitempty"`
|
||||
DBPath string `json:"db_path,omitempty"`
|
||||
LogDir string `json:"log_dir,omitempty"`
|
||||
MediaRepoPath string `json:"media_repo_path,omitempty"`
|
||||
MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering
|
||||
DeviceAliases map[string]string `json:"device_aliases,omitempty"`
|
||||
path string
|
||||
}
|
||||
|
||||
@ -17,21 +17,22 @@ var legacyBuiltinTemplateAliases = map[string]string{
|
||||
}
|
||||
|
||||
type ConfigTemplateAsset struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Origin string `json:"origin"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
NodeCount int `json:"node_count"`
|
||||
EdgeCount int `json:"edge_count"`
|
||||
MinIOEndpoint string `json:"minio_endpoint"`
|
||||
MinIOBucket string `json:"minio_bucket"`
|
||||
ExternalGetTokenURL string `json:"external_get_token_url"`
|
||||
ExternalPutMessageURL string `json:"external_put_message_url"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
AdvancedParams map[string]any `json:"advanced_params"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Origin string `json:"origin"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
Slots TemplateSlotGroup `json:"slots"`
|
||||
NodeCount int `json:"node_count"`
|
||||
EdgeCount int `json:"edge_count"`
|
||||
MinIOEndpoint string `json:"minio_endpoint"`
|
||||
MinIOBucket string `json:"minio_bucket"`
|
||||
ExternalGetTokenURL string `json:"external_get_token_url"`
|
||||
ExternalPutMessageURL string `json:"external_put_message_url"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
AdvancedParams map[string]any `json:"advanced_params"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
}
|
||||
|
||||
type ConfigProfileAsset struct {
|
||||
@ -39,9 +40,6 @@ type ConfigProfileAsset struct {
|
||||
Path string `json:"path"`
|
||||
Description string `json:"description"`
|
||||
BusinessName string `json:"business_name"`
|
||||
ObjectStorageRef string `json:"object_storage_ref"`
|
||||
TokenServiceRef string `json:"token_service_ref"`
|
||||
AlarmServiceRef string `json:"alarm_service_ref"`
|
||||
QueueSize int `json:"queue_size"`
|
||||
QueueStrategy string `json:"queue_strategy"`
|
||||
Instances []ConfigProfileInstanceAsset `json:"instances"`
|
||||
@ -51,10 +49,10 @@ type ConfigProfileAsset struct {
|
||||
type ConfigProfileInstanceAsset struct {
|
||||
Name string `json:"name"`
|
||||
Template string `json:"template"`
|
||||
VideoSourceRef string `json:"video_source_ref"`
|
||||
DisplayName string `json:"display_name"`
|
||||
DeviceCode string `json:"device_code"`
|
||||
SiteName string `json:"site_name"`
|
||||
RTSPURL string `json:"rtsp_url"`
|
||||
PublishHLSPath string `json:"publish_hls_path"`
|
||||
PublishRTSPPort string `json:"publish_rtsp_port"`
|
||||
PublishRTSPPath string `json:"publish_rtsp_path"`
|
||||
@ -107,29 +105,34 @@ type AlarmServiceConfig struct {
|
||||
TenantCode string `json:"tenant_code"`
|
||||
}
|
||||
|
||||
type ConfigVideoSourceAsset struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceTypeLabel string `json:"source_type_label"`
|
||||
Area string `json:"area"`
|
||||
Description string `json:"description"`
|
||||
RefCount int `json:"ref_count"`
|
||||
SourceSummary string `json:"source_summary"`
|
||||
Config VideoSourceConfig `json:"config"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
}
|
||||
|
||||
type VideoSourceConfig struct {
|
||||
URL string `json:"url"`
|
||||
Resolution string `json:"resolution"`
|
||||
FrameSize string `json:"frame_size"`
|
||||
FPS string `json:"fps"`
|
||||
VideoFormat string `json:"video_format"`
|
||||
FocalLength string `json:"focal_length"`
|
||||
MountHeight string `json:"mount_height"`
|
||||
MountAngle string `json:"mount_angle"`
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) {
|
||||
items := make([]ConfigTemplateAsset, 0)
|
||||
seen := map[string]bool{}
|
||||
|
||||
root := s.mediaRepoRoot()
|
||||
if root != "" {
|
||||
sources, err := listConfigSources(filepath.Join(root, "configs", "templates"))
|
||||
if err != nil {
|
||||
if s.hasExplicitRoot() {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
for _, source := range sources {
|
||||
item, err := s.templateAssetFromPath(source.Name, source.Path, "builtin", true)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, *item)
|
||||
seen[item.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s != nil && s.assets != nil {
|
||||
records, err := s.assets.ListTemplates()
|
||||
if err != nil {
|
||||
@ -140,7 +143,12 @@ func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, erro
|
||||
if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" {
|
||||
continue
|
||||
}
|
||||
item, err := s.templateAssetFromRecord(record, "user", false)
|
||||
readOnly := isStandardTemplateName(name)
|
||||
origin := "user"
|
||||
if readOnly {
|
||||
origin = "standard"
|
||||
}
|
||||
item, err := s.templateAssetFromRecord(record, origin, readOnly)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@ -163,16 +171,18 @@ func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAss
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path, ok := s.mediaAssetPath("templates", name); ok {
|
||||
return s.templateAssetFromPath(name, path, "builtin", true)
|
||||
}
|
||||
if s != nil && s.assets != nil {
|
||||
record, err := s.assets.GetTemplate(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record != nil {
|
||||
return s.templateAssetFromRecord(*record, "user", false)
|
||||
readOnly := isStandardTemplateName(name)
|
||||
origin := "user"
|
||||
if readOnly {
|
||||
origin = "standard"
|
||||
}
|
||||
return s.templateAssetFromRecord(*record, origin, readOnly)
|
||||
}
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
@ -186,7 +196,7 @@ func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.templateIsBuiltin(name) {
|
||||
if isStandardTemplateName(name) {
|
||||
return fmt.Errorf("standard template %q is read-only; please copy it before editing", name)
|
||||
}
|
||||
return s.assets.SaveTemplate(name, description, bodyJSON)
|
||||
@ -204,10 +214,10 @@ func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName strin
|
||||
if err := validateConfigName(newName); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.templateIsBuiltin(oldName) {
|
||||
if isStandardTemplateName(oldName) {
|
||||
return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName)
|
||||
}
|
||||
if oldName != newName && s.templateIsBuiltin(newName) {
|
||||
if oldName != newName && isStandardTemplateName(newName) {
|
||||
return fmt.Errorf("standard template name %q is reserved", newName)
|
||||
}
|
||||
return s.assets.RenameTemplate(oldName, newName, description, bodyJSON)
|
||||
@ -221,7 +231,7 @@ func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error {
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.templateIsBuiltin(name) {
|
||||
if isStandardTemplateName(name) {
|
||||
return fmt.Errorf("standard template %q is read-only and cannot be deleted", name)
|
||||
}
|
||||
refs, err := s.profileNamesReferencingTemplate(name)
|
||||
@ -279,12 +289,15 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
|
||||
for _, item := range instancesRaw {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
paramsMap, _ := instanceMap["params"].(map[string]any)
|
||||
sceneMeta, _ := instanceMap["scene_meta"].(map[string]any)
|
||||
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
|
||||
outputBindings, _ := instanceMap["output_bindings"].(map[string]any)
|
||||
advanced := cloneMap(paramsMap)
|
||||
for _, key := range []string{
|
||||
"display_name",
|
||||
"device_code",
|
||||
"site_name",
|
||||
"rtsp_url",
|
||||
"video_source_ref",
|
||||
"publish_hls_path",
|
||||
"publish_rtsp_port",
|
||||
"publish_rtsp_path",
|
||||
@ -298,29 +311,26 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
|
||||
instances = append(instances, ConfigProfileInstanceAsset{
|
||||
Name: stringValue(instanceMap["name"]),
|
||||
Template: stringValue(instanceMap["template"]),
|
||||
DisplayName: stringValue(paramsMap["display_name"]),
|
||||
DeviceCode: stringValue(paramsMap["device_code"]),
|
||||
SiteName: stringValue(paramsMap["site_name"]),
|
||||
RTSPURL: stringValue(paramsMap["rtsp_url"]),
|
||||
PublishHLSPath: stringValue(paramsMap["publish_hls_path"]),
|
||||
PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]),
|
||||
PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]),
|
||||
ChannelNo: stringValue(paramsMap["channel_no"]),
|
||||
VideoSourceRef: bindingField(inputBindings, "video_input_main", "video_source_ref"),
|
||||
DisplayName: stringValue(sceneMeta["display_name"]),
|
||||
DeviceCode: stringValue(sceneMeta["device_code"]),
|
||||
SiteName: stringValue(sceneMeta["site_name"]),
|
||||
PublishHLSPath: bindingField(outputBindings, "stream_output_main", "publish_hls_path"),
|
||||
PublishRTSPPort: valueString(bindingAny(outputBindings, "stream_output_main", "publish_rtsp_port")),
|
||||
PublishRTSPPath: bindingField(outputBindings, "stream_output_main", "publish_rtsp_path"),
|
||||
ChannelNo: bindingField(outputBindings, "stream_output_main", "channel_no"),
|
||||
AdvancedParams: advanced,
|
||||
})
|
||||
}
|
||||
return &ConfigProfileAsset{
|
||||
Name: firstString(raw["name"], name),
|
||||
Path: path,
|
||||
Description: stringValue(raw["description"]),
|
||||
BusinessName: stringValue(raw["business_name"]),
|
||||
ObjectStorageRef: stringValue(raw["object_storage_ref"]),
|
||||
TokenServiceRef: stringValue(raw["token_service_ref"]),
|
||||
AlarmServiceRef: stringValue(raw["alarm_service_ref"]),
|
||||
QueueSize: intValue(queueMap["size"]),
|
||||
QueueStrategy: stringValue(queueMap["strategy"]),
|
||||
Instances: instances,
|
||||
Raw: raw,
|
||||
Name: firstString(raw["name"], name),
|
||||
Path: path,
|
||||
Description: stringValue(raw["description"]),
|
||||
BusinessName: stringValue(raw["business_name"]),
|
||||
QueueSize: intValue(queueMap["size"]),
|
||||
QueueStrategy: stringValue(queueMap["strategy"]),
|
||||
Instances: instances,
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -385,6 +395,109 @@ func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationSer
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) ListVideoSources() ([]ConfigVideoSourceAsset, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
records, err := s.assets.ListVideoSources()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ConfigVideoSourceAsset, 0, len(records))
|
||||
for _, record := range records {
|
||||
item, err := videoSourceAssetFromRecord(record)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil {
|
||||
item.RefCount = len(refs)
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) GetVideoSource(name string) (*ConfigVideoSourceAsset, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, fmt.Errorf("基础配置仓库未初始化")
|
||||
}
|
||||
if err := validateVideoSourceName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record, err := s.assets.GetVideoSource(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
item, err := videoSourceAssetFromRecord(*record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil {
|
||||
item.RefCount = len(refs)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("基础配置仓库未初始化")
|
||||
}
|
||||
name := strings.TrimSpace(asset.Name)
|
||||
if err := validateVideoSourceName(name); err != nil {
|
||||
return fmt.Errorf("视频源名称不合法:%w", err)
|
||||
}
|
||||
sourceType := strings.TrimSpace(asset.SourceType)
|
||||
if sourceType == "" {
|
||||
return fmt.Errorf("视频源类型不能为空")
|
||||
}
|
||||
urlValue := strings.TrimSpace(asset.Config.URL)
|
||||
if urlValue == "" {
|
||||
return fmt.Errorf("视频源地址不能为空")
|
||||
}
|
||||
raw := map[string]any{
|
||||
"name": name,
|
||||
"source_type": sourceType,
|
||||
"area": strings.TrimSpace(asset.Area),
|
||||
"description": strings.TrimSpace(asset.Description),
|
||||
"config": map[string]any{},
|
||||
}
|
||||
configMap := raw["config"].(map[string]any)
|
||||
setAnyString(configMap, "url", asset.Config.URL)
|
||||
setAnyString(configMap, "resolution", asset.Config.Resolution)
|
||||
setAnyString(configMap, "frame_size", asset.Config.FrameSize)
|
||||
setAnyString(configMap, "fps", asset.Config.FPS)
|
||||
setAnyString(configMap, "video_format", asset.Config.VideoFormat)
|
||||
setAnyString(configMap, "focal_length", asset.Config.FocalLength)
|
||||
setAnyString(configMap, "mount_height", asset.Config.MountHeight)
|
||||
setAnyString(configMap, "mount_angle", asset.Config.MountAngle)
|
||||
body, err := marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body))
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) DeleteVideoSource(name string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("基础配置仓库未初始化")
|
||||
}
|
||||
if err := validateVideoSourceName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
refs, err := s.profileNamesReferencingVideoSource(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
return fmt.Errorf("视频源 %q 已被场景配置引用:%s", name, strings.Join(refs, ", "))
|
||||
}
|
||||
return s.assets.DeleteVideoSource(name)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, fmt.Errorf("asset repository is not configured")
|
||||
@ -426,6 +539,72 @@ func (s *ConfigPreviewService) DeleteIntegrationService(name string) error {
|
||||
return s.assets.DeleteIntegrationService(name)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
name := strings.TrimSpace(asset.Name)
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return fmt.Errorf("invalid third-party service name: %w", err)
|
||||
}
|
||||
serviceType := strings.TrimSpace(asset.Type)
|
||||
if serviceType == "" {
|
||||
return fmt.Errorf("third-party service type is required")
|
||||
}
|
||||
raw := map[string]any{
|
||||
"name": name,
|
||||
"type": serviceType,
|
||||
"description": strings.TrimSpace(asset.Description),
|
||||
"enabled": asset.Enabled,
|
||||
}
|
||||
configMap := map[string]any{}
|
||||
switch serviceType {
|
||||
case "object_storage":
|
||||
if asset.ObjectStorage == nil {
|
||||
return fmt.Errorf("object storage config is required")
|
||||
}
|
||||
setAnyString(configMap, "endpoint", asset.ObjectStorage.Endpoint)
|
||||
setAnyString(configMap, "bucket", asset.ObjectStorage.Bucket)
|
||||
setAnyString(configMap, "access_key", asset.ObjectStorage.AccessKey)
|
||||
setAnyString(configMap, "secret_key", asset.ObjectStorage.SecretKey)
|
||||
for _, key := range []string{"endpoint", "bucket", "access_key", "secret_key"} {
|
||||
if strings.TrimSpace(stringValue(configMap[key])) == "" {
|
||||
return fmt.Errorf("object storage %s is required", key)
|
||||
}
|
||||
}
|
||||
case "token_service":
|
||||
if asset.TokenService == nil {
|
||||
return fmt.Errorf("token service config is required")
|
||||
}
|
||||
setAnyString(configMap, "get_token_url", asset.TokenService.GetTokenURL)
|
||||
setAnyString(configMap, "username", asset.TokenService.Username)
|
||||
setAnyString(configMap, "password", asset.TokenService.Password)
|
||||
setAnyString(configMap, "tenant_code", asset.TokenService.TenantCode)
|
||||
if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" {
|
||||
return fmt.Errorf("token service get_token_url is required")
|
||||
}
|
||||
case "alarm_service":
|
||||
if asset.AlarmService == nil {
|
||||
return fmt.Errorf("alarm service config is required")
|
||||
}
|
||||
setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL)
|
||||
setAnyString(configMap, "username", asset.AlarmService.Username)
|
||||
setAnyString(configMap, "password", asset.AlarmService.Password)
|
||||
setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode)
|
||||
if strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" {
|
||||
return fmt.Errorf("alarm service put_message_url is required")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported third-party service type: %s", serviceType)
|
||||
}
|
||||
raw["config"] = configMap
|
||||
body, err := marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.assets.SaveIntegrationService(name, serviceType, strings.TrimSpace(asset.Description), asset.Enabled, string(body))
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name string) ([]string, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
@ -447,8 +626,50 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"object_storage_ref", "token_service_ref", "alarm_service_ref"} {
|
||||
if strings.TrimSpace(stringValue(raw[key])) == name {
|
||||
instances, _ := raw["instances"].([]any)
|
||||
for _, item := range instances {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
|
||||
found := false
|
||||
for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} {
|
||||
if strings.TrimSpace(bindingField(serviceBindings, slot, "service_ref")) == name {
|
||||
refs = append(refs, record.Name)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(refs)
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
records, err := s.assets.ListProfiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
refs := make([]string, 0)
|
||||
for _, record := range records {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
instances, _ := raw["instances"].([]any)
|
||||
for _, item := range instances {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
|
||||
if strings.TrimSpace(bindingField(inputBindings, "video_input_main", "video_source_ref")) == name {
|
||||
refs = append(refs, record.Name)
|
||||
break
|
||||
}
|
||||
@ -459,32 +680,17 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) {
|
||||
if s != nil && s.assets != nil {
|
||||
raw, path, ok, err := s.readRepoAssetJSON(kind, name)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if ok {
|
||||
return raw, path, nil
|
||||
}
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, "", fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
root := s.mediaRepoRoot()
|
||||
if root == "" {
|
||||
return nil, "", fmt.Errorf("media repo path is not configured")
|
||||
}
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
path := filepath.Join(root, "configs", kind, name+".json")
|
||||
body, err := os.ReadFile(path)
|
||||
raw, path, ok, err := s.readRepoAssetJSON(kind, name)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, "", err
|
||||
if ok {
|
||||
return raw, path, nil
|
||||
}
|
||||
return raw, path, nil
|
||||
return nil, "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) {
|
||||
@ -523,7 +729,7 @@ func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[
|
||||
}
|
||||
if kind == "profiles" {
|
||||
if strings.TrimSpace(record.TemplateName) != "" {
|
||||
raw["template_name"] = record.TemplateName
|
||||
raw["primary_template_name"] = record.TemplateName
|
||||
}
|
||||
if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" {
|
||||
raw["business_name"] = record.BusinessName
|
||||
@ -543,16 +749,9 @@ func cloneMap(in map[string]any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) templateAssetFromPath(name string, path string, origin string, readOnly bool) (*ConfigTemplateAsset, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildTemplateAsset(raw, path, origin, readOnly), nil
|
||||
func bindingAny(bindings map[string]any, slot string, field string) any {
|
||||
entry, _ := bindings[slot].(map[string]any)
|
||||
return entry[field]
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) templateAssetFromRecord(record storage.AssetRecord, origin string, readOnly bool) (*ConfigTemplateAsset, error) {
|
||||
@ -574,6 +773,7 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly
|
||||
paramsMap, _ := raw["params"].(map[string]any)
|
||||
nodes, _ := templateMap["nodes"].([]any)
|
||||
edges, _ := templateMap["edges"].([]any)
|
||||
slots, _ := parseTemplateSlots(raw)
|
||||
advanced := cloneMap(paramsMap)
|
||||
for _, key := range []string{
|
||||
"minio_endpoint",
|
||||
@ -594,6 +794,7 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly
|
||||
ReadOnly: readOnly,
|
||||
Description: stringValue(raw["description"]),
|
||||
Source: stringValue(raw["source"]),
|
||||
Slots: slots,
|
||||
NodeCount: len(nodes),
|
||||
EdgeCount: len(edges),
|
||||
MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]),
|
||||
@ -657,6 +858,42 @@ func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func videoSourceAssetFromRecord(record storage.VideoSourceRecord) (*ConfigVideoSourceAsset, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
configMap, _ := raw["config"].(map[string]any)
|
||||
sourceMap := raw
|
||||
if len(configMap) > 0 {
|
||||
sourceMap = configMap
|
||||
}
|
||||
item := &ConfigVideoSourceAsset{
|
||||
Name: firstString(raw["name"], record.Name),
|
||||
Path: repoAssetPath("video_sources", record.Name),
|
||||
SourceType: firstString(record.SourceType, stringValue(raw["source_type"])),
|
||||
Area: firstString(raw["area"], record.Area),
|
||||
Description: firstString(raw["description"], record.Description),
|
||||
SourceTypeLabel: videoSourceTypeLabel(firstString(record.SourceType, stringValue(raw["source_type"]))),
|
||||
Config: VideoSourceConfig{
|
||||
URL: stringValue(sourceMap["url"]),
|
||||
Resolution: stringValue(sourceMap["resolution"]),
|
||||
FrameSize: stringValue(sourceMap["frame_size"]),
|
||||
FPS: valueString(sourceMap["fps"]),
|
||||
VideoFormat: stringValue(sourceMap["video_format"]),
|
||||
FocalLength: stringValue(sourceMap["focal_length"]),
|
||||
MountHeight: stringValue(sourceMap["mount_height"]),
|
||||
MountAngle: stringValue(sourceMap["mount_angle"]),
|
||||
},
|
||||
Raw: raw,
|
||||
}
|
||||
item.SourceSummary = item.Config.URL
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func integrationTypeLabel(v string) string {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "object_storage":
|
||||
@ -670,22 +907,42 @@ func integrationTypeLabel(v string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) mediaAssetPath(kind string, name string) (string, bool) {
|
||||
root := s.mediaRepoRoot()
|
||||
if root == "" {
|
||||
return "", false
|
||||
func videoSourceTypeLabel(v string) string {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "rtsp":
|
||||
return "RTSP"
|
||||
case "rtmp":
|
||||
return "RTMP"
|
||||
case "file":
|
||||
return "文件"
|
||||
case "usb_camera":
|
||||
return "USB 摄像头"
|
||||
default:
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
path := filepath.Join(root, "configs", kind, name+".json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func validateVideoSourceName(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("不能为空")
|
||||
}
|
||||
return path, true
|
||||
if strings.Contains(name, "..") {
|
||||
return fmt.Errorf("不能包含 '..'")
|
||||
}
|
||||
if strings.ContainsAny(name, `/\`) {
|
||||
return fmt.Errorf("不能包含 / 或 \\")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) templateIsBuiltin(name string) bool {
|
||||
return isStandardTemplateName(name)
|
||||
}
|
||||
|
||||
func isStandardTemplateName(name string) bool {
|
||||
name = canonicalTemplateAssetName(name)
|
||||
_, ok := s.mediaAssetPath("templates", name)
|
||||
return ok
|
||||
return strings.HasPrefix(strings.TrimSpace(name), "std_")
|
||||
}
|
||||
|
||||
func canonicalTemplateAssetName(name string) string {
|
||||
|
||||
@ -11,10 +11,34 @@ import (
|
||||
"3588AdminBackend/internal/storage"
|
||||
)
|
||||
|
||||
func mustSaveTemplateRecord(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) {
|
||||
t.Helper()
|
||||
if err := repo.SaveTemplate(name, description, body); err != nil {
|
||||
t.Fatalf("SaveTemplate(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadFileBytes(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s): %v", path, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func mustImportPreviewAssets(t *testing.T, svc *ConfigPreviewService) {
|
||||
t.Helper()
|
||||
if _, err := svc.ImportAssetsFromMediaRepo(); err != nil {
|
||||
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
|
||||
templateBody := `{
|
||||
"name": "std_workshop_face_recognition_shoe_alarm",
|
||||
"source": "standard",
|
||||
"params": {
|
||||
"minio_endpoint": "http://10.0.0.49:9000",
|
||||
"minio_bucket": "myminio",
|
||||
@ -24,31 +48,38 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
|
||||
"snapshot_region": "us-east-1"
|
||||
},
|
||||
"template": {"nodes": [], "edges": []}
|
||||
}`)
|
||||
}`
|
||||
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
|
||||
"name": "local_3588_test",
|
||||
"business_name": "A厂区视觉识别",
|
||||
"description": "test profile",
|
||||
"queue": {"size": 8, "strategy": "drop_oldest"},
|
||||
"instances": [{
|
||||
"instances": [{
|
||||
"name": "cam1",
|
||||
"template": "std_workshop_face_recognition_shoe_alarm",
|
||||
"params": {
|
||||
"display_name": "东门入口",
|
||||
"device_code": "rk3588-a-001",
|
||||
"site_name": "A厂区",
|
||||
"rtsp_url": "rtsp://10.0.0.1/live",
|
||||
"publish_hls_path": "./web/hls/cam1/index.m3u8",
|
||||
"publish_rtsp_port": 8555,
|
||||
"publish_rtsp_path": "/live/cam1",
|
||||
"channel_no": "cam1",
|
||||
"queue_debug": true
|
||||
}
|
||||
"params": {"queue_debug": true},
|
||||
"scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"},
|
||||
"input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}},
|
||||
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}}
|
||||
}]
|
||||
}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
mustSaveTemplateRecord(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", templateBody)
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
if err := repo.SaveOverlay("face_debug", "", `{}`); err != nil {
|
||||
t.Fatalf("SaveOverlay: %v", err)
|
||||
}
|
||||
item, err := svc.GetProfileAsset("local_3588_test")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfileAsset: %v", err)
|
||||
@ -70,9 +101,6 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
|
||||
if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil {
|
||||
t.Fatalf("GetTemplateAsset: %v", err)
|
||||
} else {
|
||||
if item.MinIOEndpoint != "http://10.0.0.49:9000" || item.TenantCode != "32" {
|
||||
t.Fatalf("expected shared service params on template asset, got %#v", item)
|
||||
}
|
||||
if _, ok := item.AdvancedParams["snapshot_region"]; !ok {
|
||||
t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams)
|
||||
}
|
||||
@ -91,7 +119,14 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
|
||||
}
|
||||
}`)
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
mustImportPreviewAssets(t, svc)
|
||||
item, err := svc.GetOverlayAsset("face_debug")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOverlayAsset: %v", err)
|
||||
@ -116,34 +151,34 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
|
||||
{
|
||||
"name": "cam1",
|
||||
"template": "std_workshop_face_recognition_shoe_alarm",
|
||||
"params": {
|
||||
"display_name": "东门入口",
|
||||
"device_code": "rk3588-a-001",
|
||||
"site_name": "A厂区",
|
||||
"rtsp_url": "rtsp://10.0.0.1/live",
|
||||
"publish_hls_path": "./web/hls/cam1/index.m3u8",
|
||||
"publish_rtsp_port": 8555,
|
||||
"publish_rtsp_path": "/live/cam1",
|
||||
"channel_no": "cam1",
|
||||
"queue_debug": true
|
||||
}
|
||||
"params": {"queue_debug": true},
|
||||
"scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"},
|
||||
"input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}},
|
||||
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}}
|
||||
},
|
||||
{
|
||||
"name": "cam2",
|
||||
"template": "std_workshop_face_recognition_shoe_alarm",
|
||||
"params": {
|
||||
"display_name": "西门入口",
|
||||
"rtsp_url": "rtsp://10.0.0.2/live",
|
||||
"publish_hls_path": "./web/hls/cam2/index.m3u8",
|
||||
"publish_rtsp_port": 8555,
|
||||
"publish_rtsp_path": "/live/cam2",
|
||||
"channel_no": "cam2"
|
||||
}
|
||||
"scene_meta": {"display_name": "西门入口", "site_name": "A厂区"},
|
||||
"input_bindings": {"video_input_main": {"video_source_ref": "line_cam_02"}},
|
||||
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam2/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam2", "channel_no": "cam2"}}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveTemplate("std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
editor, err := svc.GetProfileEditor("local_3588_test")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfileEditor: %v", err)
|
||||
@ -167,7 +202,7 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
|
||||
if editor.Instances[0].Name != "cam1" || editor.Instances[0].DisplayName != "东门入口" {
|
||||
t.Fatalf("unexpected first instance summary: %#v", editor.Instances[0])
|
||||
}
|
||||
if editor.Instances[1].Name != "cam2" || editor.Instances[1].RTSPURL != "rtsp://10.0.0.2/live" {
|
||||
if editor.Instances[1].Name != "cam2" || editor.Instances[1].VideoSourceRef != "line_cam_02" {
|
||||
t.Fatalf("unexpected second instance summary: %#v", editor.Instances[1])
|
||||
}
|
||||
if editor.Instances[0].PublishRTSPPort != "8555" {
|
||||
@ -176,8 +211,8 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
|
||||
if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" {
|
||||
t.Fatalf("unexpected queue model: %#v", editor.Queue)
|
||||
}
|
||||
if editor.ObjectStorageRef != "" || editor.TokenServiceRef != "" || editor.AlarmServiceRef != "" {
|
||||
t.Fatalf("expected no integration refs in legacy fixture, got %#v", editor)
|
||||
if editor.Instances[0].VideoSourceRef != "gate_cam_01" {
|
||||
t.Fatalf("expected slot-driven video source ref, got %#v", editor.Instances[0])
|
||||
}
|
||||
if _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok {
|
||||
t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams)
|
||||
@ -192,9 +227,6 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
|
||||
Description: "test profile",
|
||||
DeviceCode: "rk3588-a-001",
|
||||
SiteName: "A厂区",
|
||||
ObjectStorageRef: "minio_main",
|
||||
TokenServiceRef: "token_main",
|
||||
AlarmServiceRef: "alarm_main",
|
||||
Queue: ConfigProfileQueueEditor{
|
||||
Size: "8",
|
||||
Strategy: "drop_oldest",
|
||||
@ -203,8 +235,8 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
|
||||
{
|
||||
Name: "cam1",
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
VideoSourceRef: "gate_cam_01",
|
||||
DisplayName: "东门入口",
|
||||
RTSPURL: "rtsp://10.0.0.1/live",
|
||||
PublishHLSPath: "./web/hls/cam1/index.m3u8",
|
||||
PublishRTSPPort: "8555",
|
||||
PublishRTSPPath: "/live/cam1",
|
||||
@ -216,8 +248,8 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
|
||||
{
|
||||
Name: "cam2",
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
VideoSourceRef: "line_cam_02",
|
||||
DisplayName: "视觉识别终端-B厂区",
|
||||
RTSPURL: "rtsp://10.0.0.2/live",
|
||||
PublishHLSPath: "./web/hls/cam2/index.m3u8",
|
||||
PublishRTSPPort: "8556",
|
||||
PublishRTSPPath: "/live/cam2",
|
||||
@ -237,9 +269,6 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
|
||||
if doc["business_name"] != "A厂区视觉识别" {
|
||||
t.Fatalf("unexpected business name: %#v", doc)
|
||||
}
|
||||
if doc["object_storage_ref"] != "minio_main" || doc["token_service_ref"] != "token_main" || doc["alarm_service_ref"] != "alarm_main" {
|
||||
t.Fatalf("expected integration refs in document, got %#v", doc)
|
||||
}
|
||||
queue, _ := doc["queue"].(map[string]any)
|
||||
if queue["size"] != 8 || queue["strategy"] != "drop_oldest" {
|
||||
t.Fatalf("unexpected queue doc: %#v", queue)
|
||||
@ -249,24 +278,169 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
|
||||
t.Fatalf("expected two instances, got %#v", doc["instances"])
|
||||
}
|
||||
params, _ := instances[0]["params"].(map[string]any)
|
||||
if params["publish_rtsp_port"] != 8555 {
|
||||
t.Fatalf("expected numeric rtsp port, got %#v", params["publish_rtsp_port"])
|
||||
}
|
||||
if params["queue_debug"] != true {
|
||||
t.Fatalf("expected advanced param to survive rebuild, got %#v", params)
|
||||
}
|
||||
if params["device_code"] != "rk3588-a-001" {
|
||||
t.Fatalf("expected legacy device code to be preserved in params, got %#v", params)
|
||||
}
|
||||
if params["site_name"] != "A厂区" {
|
||||
t.Fatalf("expected profile site name to be written to instance params, got %#v", params)
|
||||
if _, exists := params["video_source_ref"]; exists {
|
||||
t.Fatalf("expected new profile document to avoid legacy video_source_ref in params, got %#v", params)
|
||||
}
|
||||
params2, _ := instances[1]["params"].(map[string]any)
|
||||
if params2["publish_rtsp_path"] != "/live/cam2" {
|
||||
t.Fatalf("expected second instance to survive rebuild, got %#v", params2)
|
||||
if len(params2) != 0 {
|
||||
t.Fatalf("expected second instance params to stay empty under new model, got %#v", params2)
|
||||
}
|
||||
if params2["site_name"] != "A厂区" {
|
||||
t.Fatalf("expected profile site name on second instance, got %#v", params2)
|
||||
sceneMeta, _ := instances[0]["scene_meta"].(map[string]any)
|
||||
if sceneMeta["display_name"] != "东门入口" || sceneMeta["site_name"] != "A厂区" || sceneMeta["device_code"] != "rk3588-a-001" {
|
||||
t.Fatalf("expected scene meta to carry scene fields, got %#v", sceneMeta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) {
|
||||
svc := NewConfigPreviewService(&config.Config{})
|
||||
doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
|
||||
Name: "line_a",
|
||||
Instances: []ConfigProfileInstanceEditor{{
|
||||
Name: "cam1",
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
DisplayName: "B厂区通道1",
|
||||
SiteName: "B厂区",
|
||||
InputBindings: map[string]InputBindingEditor{
|
||||
"video_input_main": {VideoSourceRef: "gate_cam_01"},
|
||||
},
|
||||
ServiceBindings: map[string]ServiceBindingEditor{
|
||||
"object_storage_main": {ServiceRef: "minio_main"},
|
||||
"token_service_main": {ServiceRef: "token_main"},
|
||||
"alarm_service_main": {ServiceRef: "alarm_main"},
|
||||
},
|
||||
OutputBindings: map[string]OutputBindingEditor{
|
||||
"stream_output_main": {
|
||||
PublishHLSPath: "./web/hls/cam1/index.m3u8",
|
||||
PublishRTSPPort: "8555",
|
||||
PublishRTSPPath: "/live/cam1",
|
||||
ChannelNo: "cam1",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildProfileDocument: %v", err)
|
||||
}
|
||||
instances, _ := doc["instances"].([]map[string]any)
|
||||
if len(instances) != 1 {
|
||||
t.Fatalf("expected one instance, got %#v", doc["instances"])
|
||||
}
|
||||
inst := instances[0]
|
||||
inputBindings, _ := inst["input_bindings"].(map[string]any)
|
||||
if inputBindings == nil {
|
||||
t.Fatalf("expected input_bindings, got %#v", inst)
|
||||
}
|
||||
videoInput, _ := inputBindings["video_input_main"].(map[string]any)
|
||||
if videoInput["video_source_ref"] != "gate_cam_01" {
|
||||
t.Fatalf("unexpected input binding: %#v", videoInput)
|
||||
}
|
||||
serviceBindings, _ := inst["service_bindings"].(map[string]any)
|
||||
objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
|
||||
if objectStorage["service_ref"] != "minio_main" {
|
||||
t.Fatalf("unexpected service binding: %#v", serviceBindings)
|
||||
}
|
||||
outputBindings, _ := inst["output_bindings"].(map[string]any)
|
||||
streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
|
||||
if streamOutput["publish_rtsp_port"] != 8555 {
|
||||
t.Fatalf("unexpected output binding: %#v", streamOutput)
|
||||
}
|
||||
sceneMeta, _ := inst["scene_meta"].(map[string]any)
|
||||
if sceneMeta["display_name"] != "B厂区通道1" || sceneMeta["site_name"] != "B厂区" {
|
||||
t.Fatalf("unexpected scene meta: %#v", sceneMeta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVideoSources(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264","focal_length":"4mm","mount_height":"3.2m","mount_angle":"15deg"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
items, err := svc.ListVideoSources()
|
||||
if err != nil {
|
||||
t.Fatalf("ListVideoSources: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 video source, got %#v", items)
|
||||
}
|
||||
if items[0].SourceType != "rtsp" || items[0].SourceTypeLabel != "RTSP" {
|
||||
t.Fatalf("unexpected video source summary: %#v", items[0])
|
||||
}
|
||||
if items[0].Config.Resolution != "1080p" || items[0].Config.FrameSize != "1920x1080" {
|
||||
t.Fatalf("unexpected video source config: %#v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVideoSourceBlocksWhenReferenced(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
err = svc.DeleteVideoSource("gate_cam_01")
|
||||
if err == nil || !strings.Contains(err.Error(), "已被场景配置引用") {
|
||||
t.Fatalf("expected referenced delete to be blocked, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveVideoSourceAssetAllowsChineseName(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
||||
err = svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
|
||||
Name: "东门主入口",
|
||||
SourceType: "rtsp",
|
||||
Area: "东门",
|
||||
Description: "入口相机",
|
||||
Config: VideoSourceConfig{
|
||||
URL: "rtsp://10.0.0.1/live",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected chinese video source name to be accepted, got %v", err)
|
||||
}
|
||||
item, err := svc.GetVideoSource("东门主入口")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVideoSource: %v", err)
|
||||
}
|
||||
if item == nil || item.Name != "东门主入口" {
|
||||
t.Fatalf("unexpected saved item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
@ -277,8 +451,8 @@ func TestConfigPreviewServiceBuildProfileDocumentRejectsBadPort(t *testing.T) {
|
||||
Instances: []ConfigProfileInstanceEditor{
|
||||
{
|
||||
Name: "cam1",
|
||||
VideoSourceRef: "gate_cam_01",
|
||||
DisplayName: "视觉识别终端-A厂区",
|
||||
RTSPURL: "rtsp://10.0.0.1/live",
|
||||
PublishRTSPPort: "bad-port",
|
||||
},
|
||||
},
|
||||
@ -294,9 +468,9 @@ func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) {
|
||||
Name: "local_3588_test",
|
||||
Instances: []ConfigProfileInstanceEditor{
|
||||
{
|
||||
Name: "cam1",
|
||||
DisplayName: "视觉识别终端-A厂区",
|
||||
RTSPURL: "rtsp://10.0.0.1/live",
|
||||
Name: "cam1",
|
||||
VideoSourceRef: "gate_cam_01",
|
||||
DisplayName: "视觉识别终端-A厂区",
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -323,7 +497,7 @@ func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) {
|
||||
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
|
||||
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil {
|
||||
@ -427,7 +601,7 @@ func TestListIntegrationServicesCountsProfileReferences(t *testing.T) {
|
||||
); err != nil {
|
||||
t.Fatalf("SaveIntegrationService: %v", err)
|
||||
}
|
||||
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","object_storage_ref":"minio_primary","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
|
||||
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
@ -506,7 +680,7 @@ func TestDeleteIntegrationServiceBlocksWhenReferenced(t *testing.T) {
|
||||
); err != nil {
|
||||
t.Fatalf("SaveIntegrationService: %v", err)
|
||||
}
|
||||
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","object_storage_ref":"minio_primary","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
|
||||
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
@ -533,10 +707,10 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) {
|
||||
SiteName: "A厂区",
|
||||
Instances: []ConfigProfileInstanceEditor{
|
||||
{
|
||||
Name: "cam1",
|
||||
Template: "helmet",
|
||||
DisplayName: "东门入口",
|
||||
RTSPURL: "rtsp://10.0.0.1/live",
|
||||
Name: "cam1",
|
||||
Template: "helmet",
|
||||
DisplayName: "东门入口",
|
||||
VideoSourceRef: "gate_cam_01",
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -563,7 +737,7 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T) {
|
||||
func TestConfigPreviewServicePrefersRepoTemplateOverBuiltinFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{
|
||||
"name": "helmet",
|
||||
@ -585,24 +759,23 @@ func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTemplateAsset: %v", err)
|
||||
}
|
||||
if !item.ReadOnly || item.Origin != "builtin" {
|
||||
t.Fatalf("expected builtin readonly template, got %#v", item)
|
||||
if item.ReadOnly || item.Origin != "user" {
|
||||
t.Fatalf("expected sqlite template to be preferred, got %#v", item)
|
||||
}
|
||||
if item.Description != "builtin template" {
|
||||
t.Fatalf("expected builtin template payload, got %#v", item)
|
||||
if item.Description != "shadow template" {
|
||||
t.Fatalf("expected sqlite template payload, got %#v", item)
|
||||
}
|
||||
items, err := svc.ListTemplateAssets()
|
||||
if err != nil {
|
||||
t.Fatalf("ListTemplateAssets: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Name != "helmet" || !items[0].ReadOnly {
|
||||
t.Fatalf("expected only builtin template in merged list, got %#v", items)
|
||||
if len(items) != 1 || items[0].Name != "helmet" || items[0].ReadOnly {
|
||||
t.Fatalf("expected only sqlite template in merged list, got %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) {
|
||||
func TestConfigPreviewServiceRejectsSavingStandardTemplateName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
@ -611,7 +784,7 @@ func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) {
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
|
||||
err = svc.SaveTemplateAsset("helmet", "new body", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
|
||||
err = svc.SaveTemplateAsset("std_face_recognition_stream", "new body", `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "read-only") {
|
||||
t.Fatalf("expected readonly rejection, got %v", err)
|
||||
}
|
||||
@ -627,7 +800,7 @@ func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T)
|
||||
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
|
||||
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
@ -663,7 +836,7 @@ func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) {
|
||||
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
|
||||
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
@ -674,10 +847,10 @@ func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) {
|
||||
func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`)
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
@ -691,14 +864,51 @@ func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
|
||||
}
|
||||
if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 {
|
||||
if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 {
|
||||
t.Fatalf("unexpected import result: %#v", result)
|
||||
}
|
||||
record, err := repo.GetTemplate("helmet")
|
||||
record, err := repo.GetTemplate("std_face_recognition_stream")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTemplate: %v", err)
|
||||
}
|
||||
if record != nil {
|
||||
t.Fatalf("expected builtin template to stay out of sqlite, got %#v", record)
|
||||
if record == nil || !strings.Contains(record.BodyJSON, `"source": "standard"`) && !strings.Contains(record.BodyJSON, `"source":"standard"`) {
|
||||
t.Fatalf("expected standard template to be imported into sqlite, got %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`)
|
||||
mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`)
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveTemplate("std_service_test_stream", "existing service", `{"name":"std_service_test_stream","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
|
||||
imported, err := ImportStandardTemplatesFromDir(repo, root)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportStandardTemplatesFromDir: %v", err)
|
||||
}
|
||||
if imported != 1 {
|
||||
t.Fatalf("expected one imported standard template, got %d", imported)
|
||||
}
|
||||
face, err := repo.GetTemplate("std_face_recognition_stream")
|
||||
if err != nil || face == nil {
|
||||
t.Fatalf("expected imported face template, got %#v err=%v", face, err)
|
||||
}
|
||||
if !strings.Contains(face.BodyJSON, `"source": "standard"`) && !strings.Contains(face.BodyJSON, `"source":"standard"`) {
|
||||
t.Fatalf("expected imported template source marker, got %#v", face)
|
||||
}
|
||||
serviceRecord, err := repo.GetTemplate("std_service_test_stream")
|
||||
if err != nil || serviceRecord == nil {
|
||||
t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err)
|
||||
}
|
||||
if serviceRecord.Description != "existing service" {
|
||||
t.Fatalf("expected existing template preserved, got %#v", serviceRecord)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
@ -70,159 +68,105 @@ func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *C
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) {
|
||||
root := s.mediaRepoRoot()
|
||||
out := ConfigPreviewSources{Root: root}
|
||||
seenTemplates := map[string]bool{}
|
||||
if root != "" {
|
||||
templates, err := listConfigSources(filepath.Join(root, "configs", "templates"))
|
||||
if err != nil {
|
||||
if s.hasExplicitRoot() {
|
||||
return out, err
|
||||
}
|
||||
} else {
|
||||
out.Templates = append(out.Templates, templates...)
|
||||
for _, item := range templates {
|
||||
seenTemplates[item.Name] = true
|
||||
}
|
||||
}
|
||||
profiles, err := listConfigSources(filepath.Join(root, "configs", "profiles"))
|
||||
if err != nil {
|
||||
if s.hasExplicitRoot() {
|
||||
return out, err
|
||||
}
|
||||
} else {
|
||||
out.Profiles = profiles
|
||||
}
|
||||
overlays, err := listConfigSources(filepath.Join(root, "configs", "overlays"))
|
||||
if err != nil {
|
||||
if s.hasExplicitRoot() {
|
||||
return out, err
|
||||
}
|
||||
} else {
|
||||
out.Overlays = overlays
|
||||
}
|
||||
}
|
||||
|
||||
if s != nil && s.assets != nil {
|
||||
templates, err := s.assets.ListTemplates()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, item := range templates {
|
||||
if seenTemplates[item.Name] {
|
||||
continue
|
||||
}
|
||||
out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
|
||||
}
|
||||
profiles, err := s.assets.ListProfiles()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if len(profiles) > 0 {
|
||||
out.Profiles = out.Profiles[:0]
|
||||
for _, item := range profiles {
|
||||
out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
|
||||
}
|
||||
}
|
||||
overlays, err := s.assets.ListOverlays()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if len(overlays) > 0 {
|
||||
out.Overlays = out.Overlays[:0]
|
||||
for _, item := range overlays {
|
||||
out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name })
|
||||
sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name })
|
||||
sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name })
|
||||
if out.Root == "" && s != nil && s.assets != nil && (len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0) {
|
||||
out.Root = "SQLite"
|
||||
}
|
||||
if out.Root == "" && len(out.Templates) == 0 && len(out.Profiles) == 0 && len(out.Overlays) == 0 {
|
||||
return defaultConfigPreviewSources(""), nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) listRepoSources() (ConfigPreviewSources, bool, error) {
|
||||
out := ConfigPreviewSources{}
|
||||
if s == nil || s.assets == nil {
|
||||
return ConfigPreviewSources{}, false, nil
|
||||
return out, nil
|
||||
}
|
||||
templates, err := s.assets.ListTemplates()
|
||||
if err != nil {
|
||||
return ConfigPreviewSources{}, true, err
|
||||
return out, err
|
||||
}
|
||||
for _, item := range templates {
|
||||
out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
|
||||
}
|
||||
profiles, err := s.assets.ListProfiles()
|
||||
if err != nil {
|
||||
return ConfigPreviewSources{}, true, err
|
||||
}
|
||||
overlays, err := s.assets.ListOverlays()
|
||||
if err != nil {
|
||||
return ConfigPreviewSources{}, true, err
|
||||
}
|
||||
if len(templates) == 0 && len(profiles) == 0 && len(overlays) == 0 {
|
||||
return ConfigPreviewSources{}, false, nil
|
||||
}
|
||||
out := ConfigPreviewSources{Root: "SQLite"}
|
||||
for _, item := range templates {
|
||||
out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
|
||||
return out, err
|
||||
}
|
||||
for _, item := range profiles {
|
||||
out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
|
||||
}
|
||||
overlays, err := s.assets.ListOverlays()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, item := range overlays {
|
||||
out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
|
||||
}
|
||||
return out, true, nil
|
||||
sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name })
|
||||
sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name })
|
||||
sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name })
|
||||
if len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0 {
|
||||
out.Root = "SQLite"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
|
||||
root := s.mediaRepoRoot()
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("media repo path is not configured")
|
||||
if err := validateConfigName(req.Template); err != nil {
|
||||
return nil, fmt.Errorf("invalid template: %w", err)
|
||||
}
|
||||
templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
|
||||
profilePath := filepath.Join(root, "configs", "profiles", req.Profile+".json")
|
||||
return s.renderFromPaths(root, req, templatePath, profilePath)
|
||||
if strings.TrimSpace(req.Profile) != "" {
|
||||
if err := validateConfigName(req.Profile); err != nil {
|
||||
return nil, fmt.Errorf("invalid profile: %w", err)
|
||||
}
|
||||
}
|
||||
for _, overlay := range req.Overlays {
|
||||
if err := validateConfigName(overlay); err != nil {
|
||||
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
|
||||
}
|
||||
}
|
||||
templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileRaw, profilePath, err := s.readAssetJSON("profiles", req.Profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlays := make([]runtimeOverlayInput, 0, len(req.Overlays))
|
||||
for _, overlay := range req.Overlays {
|
||||
raw, path, err := s.readAssetJSON("overlays", overlay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
|
||||
}
|
||||
return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
|
||||
root := s.mediaRepoRoot()
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("media repo path is not configured")
|
||||
if err := validateConfigName(req.Template); err != nil {
|
||||
return nil, fmt.Errorf("invalid template: %w", err)
|
||||
}
|
||||
for _, overlay := range req.Overlays {
|
||||
if err := validateConfigName(overlay); err != nil {
|
||||
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
|
||||
}
|
||||
}
|
||||
doc, err := s.BuildProfileDocument(editor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := marshalConfigJSON(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempProfile, err := os.CreateTemp("", "rk3588-profile-editor-*.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempProfilePath := tempProfile.Name()
|
||||
if _, err := tempProfile.Write(body); err != nil {
|
||||
_ = tempProfile.Close()
|
||||
_ = os.Remove(tempProfilePath)
|
||||
return nil, err
|
||||
}
|
||||
_ = tempProfile.Close()
|
||||
defer os.Remove(tempProfilePath)
|
||||
|
||||
if strings.TrimSpace(req.Profile) == "" {
|
||||
req.Profile = strings.TrimSpace(editor.Name)
|
||||
}
|
||||
templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
|
||||
return s.renderFromPaths(root, req, templatePath, tempProfilePath)
|
||||
templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlays := make([]runtimeOverlayInput, 0, len(req.Overlays))
|
||||
for _, overlay := range req.Overlays {
|
||||
raw, path, err := s.readAssetJSON("overlays", overlay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
|
||||
}
|
||||
return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewRequest, templatePath string, profilePath string) (*ConfigPreviewResult, error) {
|
||||
func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) {
|
||||
if err := validateConfigName(req.Template); err != nil {
|
||||
return nil, fmt.Errorf("invalid template: %w", err)
|
||||
}
|
||||
@ -242,76 +186,28 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq
|
||||
if req.ConfigVersion == "" {
|
||||
req.ConfigVersion = time.Now().Format("20060102.150405")
|
||||
}
|
||||
if _, err := os.Stat(templatePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(profilePath); err != nil {
|
||||
return nil, fmt.Errorf("invalid profile: %w", err)
|
||||
}
|
||||
profileRaw, err := readConfigJSONFile(profilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read profile for integrations: %w", err)
|
||||
}
|
||||
integrationParams, err := s.integrationParamsForProfile(profileRaw)
|
||||
resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedTemplatePath := templatePath
|
||||
if len(integrationParams) > 0 {
|
||||
tempTemplatePath, err := buildResolvedTemplateFile(templatePath, integrationParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedTemplatePath = tempTemplatePath
|
||||
defer os.Remove(tempTemplatePath)
|
||||
}
|
||||
|
||||
out, err := os.CreateTemp("", "rk3588-config-preview-*.json")
|
||||
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, map[string]any{
|
||||
"config_id": req.ConfigID,
|
||||
"config_version": req.ConfigVersion,
|
||||
"rendered_at": time.Now().Format(time.RFC3339),
|
||||
"rendered_by": "managerd",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outPath := out.Name()
|
||||
_ = out.Close()
|
||||
defer os.Remove(outPath)
|
||||
|
||||
args := []string{
|
||||
filepath.Join(root, "tools", "render_config.py"),
|
||||
"--template", resolvedTemplatePath,
|
||||
"--profile", profilePath,
|
||||
"--out", outPath,
|
||||
"--config-id", req.ConfigID,
|
||||
"--config-version", req.ConfigVersion,
|
||||
"--rendered-at", time.Now().Format(time.RFC3339),
|
||||
}
|
||||
for _, overlay := range req.Overlays {
|
||||
args = append(args, "--overlay", filepath.Join(root, "configs", "overlays", overlay+".json"))
|
||||
}
|
||||
|
||||
cmd := exec.Command("python", args...)
|
||||
cmd.Dir = root
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return nil, fmt.Errorf("render config preview: %s", msg)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(outPath)
|
||||
body, err := marshalConfigJSON(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, _ := doc["metadata"].(map[string]any)
|
||||
sum := sha256.Sum256(body)
|
||||
return &ConfigPreviewResult{
|
||||
Request: req,
|
||||
Root: root,
|
||||
Root: previewRenderRoot(templatePath, profilePath),
|
||||
Sha256: hex.EncodeToString(sum[:]),
|
||||
Size: len(body),
|
||||
Metadata: metadata,
|
||||
@ -319,114 +215,160 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) integrationParamsForProfile(raw map[string]any) (map[string]any, error) {
|
||||
params := map[string]any{}
|
||||
func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) {
|
||||
if raw == nil {
|
||||
return params, nil
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
type refDef struct {
|
||||
key string
|
||||
expected string
|
||||
apply func(asset *ConfigIntegrationServiceAsset)
|
||||
body, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defs := []refDef{
|
||||
{
|
||||
key: "object_storage_ref",
|
||||
expected: "object_storage",
|
||||
apply: func(asset *ConfigIntegrationServiceAsset) {
|
||||
if asset.ObjectStorage == nil {
|
||||
return
|
||||
}
|
||||
setAnyString(params, "minio_endpoint", asset.ObjectStorage.Endpoint)
|
||||
setAnyString(params, "minio_bucket", asset.ObjectStorage.Bucket)
|
||||
setAnyString(params, "minio_access_key", asset.ObjectStorage.AccessKey)
|
||||
setAnyString(params, "minio_secret_key", asset.ObjectStorage.SecretKey)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "token_service_ref",
|
||||
expected: "token_service",
|
||||
apply: func(asset *ConfigIntegrationServiceAsset) {
|
||||
if asset.TokenService == nil {
|
||||
return
|
||||
}
|
||||
setAnyString(params, "external_get_token_url", asset.TokenService.GetTokenURL)
|
||||
setAnyString(params, "tenant_code", asset.TokenService.TenantCode)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "alarm_service_ref",
|
||||
expected: "alarm_service",
|
||||
apply: func(asset *ConfigIntegrationServiceAsset) {
|
||||
if asset.AlarmService == nil {
|
||||
return
|
||||
}
|
||||
setAnyString(params, "external_put_message_url", asset.AlarmService.PutMessageURL)
|
||||
setAnyString(params, "tenant_code", asset.AlarmService.TenantCode)
|
||||
},
|
||||
},
|
||||
var clone map[string]any
|
||||
if err := json.Unmarshal(body, &clone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, def := range defs {
|
||||
refName := strings.TrimSpace(stringValue(raw[def.key]))
|
||||
if refName == "" {
|
||||
continue
|
||||
instances, _ := clone["instances"].([]any)
|
||||
for _, item := range instances {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
paramsMap, _ := instanceMap["params"].(map[string]any)
|
||||
if paramsMap == nil {
|
||||
paramsMap = map[string]any{}
|
||||
instanceMap["params"] = paramsMap
|
||||
}
|
||||
asset, err := s.GetIntegrationService(refName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load %s %q: %w", def.key, refName, err)
|
||||
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
|
||||
if inputBindings == nil {
|
||||
inputBindings = map[string]any{}
|
||||
instanceMap["input_bindings"] = inputBindings
|
||||
}
|
||||
if strings.TrimSpace(asset.Type) != def.expected {
|
||||
return nil, fmt.Errorf("%s %q has type %q, expected %q", def.key, refName, asset.Type, def.expected)
|
||||
videoSourceRef := bindingField(inputBindings, "video_input_main", "video_source_ref")
|
||||
if videoSourceRef != "" {
|
||||
asset, err := s.GetVideoSource(videoSourceRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load video_source_ref %q: %w", videoSourceRef, err)
|
||||
}
|
||||
entry, _ := inputBindings["video_input_main"].(map[string]any)
|
||||
if entry == nil {
|
||||
entry = map[string]any{}
|
||||
}
|
||||
entry["video_source_ref"] = videoSourceRef
|
||||
entry["resolved"] = map[string]any{
|
||||
"url": asset.Config.URL,
|
||||
"resolution": asset.Config.Resolution,
|
||||
"frame_size": asset.Config.FrameSize,
|
||||
"fps": asset.Config.FPS,
|
||||
"video_format": asset.Config.VideoFormat,
|
||||
}
|
||||
inputBindings["video_input_main"] = entry
|
||||
}
|
||||
|
||||
serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
|
||||
if serviceBindings == nil {
|
||||
serviceBindings = map[string]any{}
|
||||
instanceMap["service_bindings"] = serviceBindings
|
||||
}
|
||||
for _, binding := range []struct {
|
||||
slot string
|
||||
expected string
|
||||
}{
|
||||
{slot: "object_storage_main", expected: "object_storage"},
|
||||
{slot: "token_service_main", expected: "token_service"},
|
||||
{slot: "alarm_service_main", expected: "alarm_service"},
|
||||
} {
|
||||
serviceRef := bindingField(serviceBindings, binding.slot, "service_ref")
|
||||
if serviceRef == "" {
|
||||
continue
|
||||
}
|
||||
asset, err := s.GetIntegrationService(serviceRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load service_ref %q: %w", serviceRef, err)
|
||||
}
|
||||
if strings.TrimSpace(asset.Type) != binding.expected {
|
||||
return nil, fmt.Errorf("service_ref %q has type %q, expected %q", serviceRef, asset.Type, binding.expected)
|
||||
}
|
||||
entry, _ := serviceBindings[binding.slot].(map[string]any)
|
||||
if entry == nil {
|
||||
entry = map[string]any{}
|
||||
}
|
||||
entry["service_ref"] = serviceRef
|
||||
entry["resolved"] = resolvedServiceBinding(asset)
|
||||
serviceBindings[binding.slot] = entry
|
||||
}
|
||||
def.apply(asset)
|
||||
}
|
||||
return params, nil
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func buildResolvedTemplateFile(templatePath string, params map[string]any) (string, error) {
|
||||
raw, err := readConfigJSONFile(templatePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func bindingField(bindings map[string]any, slot string, field string) string {
|
||||
entry, _ := bindings[slot].(map[string]any)
|
||||
return stringValue(entry[field])
|
||||
}
|
||||
|
||||
func resolvedServiceBinding(asset *ConfigIntegrationServiceAsset) map[string]any {
|
||||
if asset == nil {
|
||||
return nil
|
||||
}
|
||||
paramsMap, _ := raw["params"].(map[string]any)
|
||||
if paramsMap == nil {
|
||||
paramsMap = map[string]any{}
|
||||
switch asset.Type {
|
||||
case "object_storage":
|
||||
if asset.ObjectStorage == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"endpoint": asset.ObjectStorage.Endpoint,
|
||||
"bucket": asset.ObjectStorage.Bucket,
|
||||
"access_key": asset.ObjectStorage.AccessKey,
|
||||
"secret_key": asset.ObjectStorage.SecretKey,
|
||||
}
|
||||
case "token_service":
|
||||
if asset.TokenService == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"get_token_url": asset.TokenService.GetTokenURL,
|
||||
"username": asset.TokenService.Username,
|
||||
"password": asset.TokenService.Password,
|
||||
"tenant_code": asset.TokenService.TenantCode,
|
||||
}
|
||||
case "alarm_service":
|
||||
if asset.AlarmService == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"put_message_url": asset.AlarmService.PutMessageURL,
|
||||
"username": asset.AlarmService.Username,
|
||||
"password": asset.AlarmService.Password,
|
||||
"tenant_code": asset.AlarmService.TenantCode,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
for key, value := range params {
|
||||
paramsMap[key] = value
|
||||
}
|
||||
|
||||
func previewRenderRoot(templatePath string, profilePath string) string {
|
||||
if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") {
|
||||
return "SQLite"
|
||||
}
|
||||
raw["params"] = paramsMap
|
||||
if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." {
|
||||
return dir
|
||||
}
|
||||
return "managerd"
|
||||
}
|
||||
|
||||
func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) {
|
||||
body, err := marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tempTemplate, err := os.CreateTemp("", "rk3588-template-resolved-*.json")
|
||||
tempFile, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tempTemplatePath := tempTemplate.Name()
|
||||
if _, err := tempTemplate.Write(body); err != nil {
|
||||
_ = tempTemplate.Close()
|
||||
_ = os.Remove(tempTemplatePath)
|
||||
path := tempFile.Name()
|
||||
if _, err := tempFile.Write(body); err != nil {
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(path)
|
||||
return "", err
|
||||
}
|
||||
_ = tempTemplate.Close()
|
||||
return tempTemplatePath, nil
|
||||
}
|
||||
|
||||
func readConfigJSONFile(path string) (map[string]any, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
return raw, nil
|
||||
_ = tempFile.Close()
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func setAnyString(m map[string]any, key string, value string) {
|
||||
@ -436,36 +378,21 @@ func setAnyString(m map[string]any, key string, value string) {
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) mediaRepoRoot() string {
|
||||
if s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" {
|
||||
return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
|
||||
}
|
||||
if env := strings.TrimSpace(os.Getenv("ORANGEPI_MEDIA_REPO")); env != "" {
|
||||
return filepath.Clean(env)
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
if s.cfg == nil {
|
||||
return ""
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(wd, "..", "OrangePi3588Media"),
|
||||
filepath.Join(wd, "..", "..", "OrangePi3588Media"),
|
||||
filepath.Join(filepath.Dir(wd), "OrangePi3588Media"),
|
||||
if strings.TrimSpace(s.cfg.MediaRepoPath) == "" {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(filepath.Join(candidate, "tools", "render_config.py")); err == nil {
|
||||
return filepath.Clean(candidate)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) hasExplicitRoot() bool {
|
||||
return s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != ""
|
||||
return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
|
||||
}
|
||||
|
||||
func listConfigSources(dir string) ([]ConfigSource, error) {
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []ConfigSource{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ConfigSource, 0)
|
||||
@ -490,45 +417,24 @@ func validateConfigName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultConfigPreviewSources(root string) ConfigPreviewSources {
|
||||
return ConfigPreviewSources{
|
||||
Root: root,
|
||||
Templates: []ConfigSource{
|
||||
{Name: "std_face_recognition_stream"},
|
||||
{Name: "std_service_test_stream"},
|
||||
{Name: "std_workshoe_detection_stream"},
|
||||
{Name: "std_workshop_face_recognition_shoe_alarm"},
|
||||
},
|
||||
Profiles: []ConfigSource{
|
||||
{Name: "local_3588_test"},
|
||||
},
|
||||
Overlays: []ConfigSource{
|
||||
{Name: "face_debug"},
|
||||
{Name: "face_test_sensitive"},
|
||||
{Name: "production_quiet"},
|
||||
{Name: "shoe_debug"},
|
||||
{Name: "shoe_test_sensitive"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func repoAssetPath(kind string, name string) string {
|
||||
return "sqlite:" + kind + "/" + strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportResult, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, fmt.Errorf("assets repository is not configured")
|
||||
return nil, fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
root := s.mediaRepoRoot()
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("media repo path is not configured")
|
||||
return nil, fmt.Errorf("legacy import source path is not configured")
|
||||
}
|
||||
result := &ConfigAssetImportResult{Root: root}
|
||||
for _, item := range []struct {
|
||||
kind string
|
||||
inc *int
|
||||
}{
|
||||
{kind: "templates", inc: &result.Templates},
|
||||
{kind: "profiles", inc: &result.Profiles},
|
||||
{kind: "overlays", inc: &result.Overlays},
|
||||
} {
|
||||
@ -549,6 +455,16 @@ func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportRe
|
||||
description := stringValue(raw["description"])
|
||||
switch item.kind {
|
||||
case "templates":
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
if isStandardTemplateName(name) && strings.TrimSpace(stringValue(raw["source"])) == "" {
|
||||
raw["source"] = "standard"
|
||||
body, err = marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.assets.SaveTemplate(name, description, string(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -571,21 +487,13 @@ func (s *ConfigPreviewService) ExportAssetJSON(kind string, name string) ([]byte
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if s != nil && s.assets != nil {
|
||||
if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
|
||||
return body, name + ".json", err
|
||||
}
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, "", fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
root := s.mediaRepoRoot()
|
||||
if root == "" {
|
||||
return nil, "", fmt.Errorf("media repo path is not configured")
|
||||
if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
|
||||
return body, name + ".json", err
|
||||
}
|
||||
path := filepath.Join(root, "configs", kind, name+".json")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return body, name + ".json", nil
|
||||
return nil, "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) exportRepoAssetJSON(kind string, name string) ([]byte, bool, error) {
|
||||
@ -620,5 +528,5 @@ func profileRawTemplateName(raw map[string]any) string {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return stringValue(raw["template_name"])
|
||||
return stringValue(raw["primary_template_name"])
|
||||
}
|
||||
|
||||
@ -11,20 +11,34 @@ import (
|
||||
"3588AdminBackend/internal/storage"
|
||||
)
|
||||
|
||||
func mustImportAssetsFromMediaRepo(t *testing.T, svc *ConfigPreviewService) {
|
||||
t.Helper()
|
||||
if _, err := svc.ImportAssetsFromMediaRepo(); err != nil {
|
||||
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceListsSources(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
mustImportAssetsFromMediaRepo(t, svc)
|
||||
sources, err := svc.ListSources()
|
||||
if err != nil {
|
||||
t.Fatalf("ListSources: %v", err)
|
||||
}
|
||||
|
||||
if sources.Root != root {
|
||||
t.Fatalf("expected root %q, got %q", root, sources.Root)
|
||||
if sources.Root != "SQLite" {
|
||||
t.Fatalf("expected root %q, got %q", "SQLite", sources.Root)
|
||||
}
|
||||
if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" {
|
||||
t.Fatalf("unexpected templates: %v", got)
|
||||
@ -37,6 +51,35 @@ func TestConfigPreviewServiceListsSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceListSourcesAllowsEmptyConfigsDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
if err := repo.SaveProfile("line_a", "helmet", "Line A", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"helmet","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
sources, err := svc.ListSources()
|
||||
if err != nil {
|
||||
t.Fatalf("ListSources: %v", err)
|
||||
}
|
||||
if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
|
||||
t.Fatalf("unexpected templates: %#v", got)
|
||||
}
|
||||
if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "line_a" {
|
||||
t.Fatalf("unexpected profiles: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
|
||||
@ -54,8 +97,8 @@ func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) {
|
||||
|
||||
func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"helmet template","template":{"nodes":[],"edges":[]}}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
|
||||
mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","description":"overlay","instance_overrides":{"cam1":{"override":{}}}}`)
|
||||
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
@ -70,7 +113,7 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
|
||||
}
|
||||
if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 {
|
||||
if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 {
|
||||
t.Fatalf("unexpected import result: %#v", result)
|
||||
}
|
||||
|
||||
@ -78,13 +121,13 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ListSources: %v", err)
|
||||
}
|
||||
if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
|
||||
if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "std_face_recognition_stream" {
|
||||
t.Fatalf("unexpected templates after import: %#v", got)
|
||||
}
|
||||
if record, err := repo.GetTemplate("helmet"); err != nil {
|
||||
if record, err := repo.GetTemplate("std_face_recognition_stream"); err != nil {
|
||||
t.Fatalf("GetTemplate: %v", err)
|
||||
} else if record != nil {
|
||||
t.Fatalf("expected builtin template to remain outside sqlite, got %#v", record)
|
||||
} else if record == nil {
|
||||
t.Fatal("expected imported standard template")
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,82 +157,18 @@ func TestConfigPreviewServiceExportsAssetJSONFromSQLite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceIntegrationParamsForProfile(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
params, err := svc.integrationParamsForProfile(map[string]any{
|
||||
"object_storage_ref": "minio_main",
|
||||
"token_service_ref": "token_main",
|
||||
"alarm_service_ref": "alarm_main",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("integrationParamsForProfile: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"minio_endpoint": "http://10.0.0.49:9000",
|
||||
"minio_bucket": "myminio",
|
||||
"minio_access_key": "admin",
|
||||
"minio_secret_key": "password",
|
||||
"external_get_token_url": "http://10.0.0.49:8080/api/getToken",
|
||||
"external_put_message_url": "http://10.0.0.49:8080/api/putMessage",
|
||||
"tenant_code": "32",
|
||||
} {
|
||||
if got := params[key]; got != want {
|
||||
t.Fatalf("expected %s=%q, got %#v", key, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRenderProfileEditorExpandsIntegrationRefs(t *testing.T) {
|
||||
func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
|
||||
"name":"std_workshop_face_recognition_shoe_alarm",
|
||||
"params":{"existing":"keep"},
|
||||
"template":{"nodes":[],"edges":[]}
|
||||
"template":{
|
||||
"nodes":[
|
||||
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
|
||||
{"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]}
|
||||
],
|
||||
"edges":[]
|
||||
}
|
||||
}`)
|
||||
mustWrite(t, filepath.Join(root, "tools", "render_config.py"), `import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--template", required=True)
|
||||
parser.add_argument("--profile", required=True)
|
||||
parser.add_argument("--out", required=True)
|
||||
parser.add_argument("--config-id", required=True)
|
||||
parser.add_argument("--config-version", required=True)
|
||||
parser.add_argument("--rendered-at", required=True)
|
||||
parser.add_argument("--overlay", action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.template, "r", encoding="utf-8") as fh:
|
||||
template = json.load(fh)
|
||||
with open(args.profile, "r", encoding="utf-8") as fh:
|
||||
profile = json.load(fh)
|
||||
|
||||
doc = {
|
||||
"metadata": {
|
||||
"template": template.get("name"),
|
||||
"profile": profile.get("name"),
|
||||
},
|
||||
"template_params": template.get("params", {}),
|
||||
"profile_refs": {
|
||||
"object_storage_ref": profile.get("object_storage_ref"),
|
||||
"token_service_ref": profile.get("token_service_ref"),
|
||||
"alarm_service_ref": profile.get("alarm_service_ref"),
|
||||
}
|
||||
}
|
||||
with open(args.out, "w", encoding="utf-8") as fh:
|
||||
json.dump(doc, fh, ensure_ascii=False, indent=2)
|
||||
`)
|
||||
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
@ -197,20 +176,36 @@ with open(args.out, "w", encoding="utf-8") as fh:
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
mustImportAssetsFromMediaRepo(t, svc)
|
||||
editor := ConfigProfileEditor{
|
||||
Name: "line_a",
|
||||
ObjectStorageRef: "minio_main",
|
||||
TokenServiceRef: "token_main",
|
||||
AlarmServiceRef: "alarm_main",
|
||||
Name: "line_a",
|
||||
Instances: []ConfigProfileInstanceEditor{{
|
||||
Name: "cam1",
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
RTSPURL: "rtsp://10.0.0.1/live",
|
||||
Name: "cam1",
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
VideoSourceRef: "gate_cam_01",
|
||||
PublishHLSPath: "./web/hls/cam1/index.m3u8",
|
||||
PublishRTSPPort: "8555",
|
||||
PublishRTSPPath: "/live/cam1",
|
||||
ChannelNo: "cam1",
|
||||
ServiceBindings: map[string]ServiceBindingEditor{
|
||||
"object_storage_main": {ServiceRef: "minio_main"},
|
||||
"token_service_main": {ServiceRef: "token_main"},
|
||||
"alarm_service_main": {ServiceRef: "alarm_main"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
@ -227,20 +222,181 @@ with open(args.out, "w", encoding="utf-8") as fh:
|
||||
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
|
||||
t.Fatalf("unmarshal render result: %v", err)
|
||||
}
|
||||
templateParams, _ := doc["template_params"].(map[string]any)
|
||||
for key, want := range map[string]string{
|
||||
"existing": "keep",
|
||||
"minio_endpoint": "http://10.0.0.49:9000",
|
||||
"minio_bucket": "myminio",
|
||||
"minio_access_key": "admin",
|
||||
"minio_secret_key": "password",
|
||||
"external_get_token_url": "http://10.0.0.49:8080/api/getToken",
|
||||
"external_put_message_url": "http://10.0.0.49:8080/api/putMessage",
|
||||
"tenant_code": "32",
|
||||
} {
|
||||
if got := stringValue(templateParams[key]); got != want {
|
||||
t.Fatalf("expected template param %s=%q, got %#v", key, want, templateParams[key])
|
||||
}
|
||||
templates, _ := doc["templates"].(map[string]any)
|
||||
renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
|
||||
nodes, _ := renderedTemplate["nodes"].([]any)
|
||||
inputNode, _ := nodes[0].(map[string]any)
|
||||
if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
|
||||
t.Fatalf("expected expanded input url, got %#v", inputNode)
|
||||
}
|
||||
publishNode, _ := nodes[1].(map[string]any)
|
||||
outputs, _ := publishNode["outputs"].([]any)
|
||||
output, _ := outputs[0].(map[string]any)
|
||||
if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" {
|
||||
t.Fatalf("expected expanded output path, got %#v", output)
|
||||
}
|
||||
metadata, _ := doc["metadata"].(map[string]any)
|
||||
if got := stringValue(metadata["rendered_by"]); got != "managerd" {
|
||||
t.Fatalf("expected managerd renderer metadata, got %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
|
||||
"name":"std_service_test_stream",
|
||||
"template":{
|
||||
"nodes":[
|
||||
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
|
||||
{"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]}
|
||||
],
|
||||
"edges":[]
|
||||
}
|
||||
}`)
|
||||
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{
|
||||
"name":"line_a",
|
||||
"business_name":"Line A",
|
||||
"instances":[
|
||||
{
|
||||
"name":"cam1",
|
||||
"template":"std_service_test_stream",
|
||||
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
|
||||
"output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8","publish_rtsp_port":8555,"publish_rtsp_path":"/live/cam1","channel_no":"cam1"}}
|
||||
}
|
||||
]
|
||||
}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil {
|
||||
t.Fatalf("SaveOverlay: %v", err)
|
||||
}
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
mustImportAssetsFromMediaRepo(t, svc)
|
||||
result, err := svc.Render(ConfigPreviewRequest{
|
||||
Template: "std_service_test_stream",
|
||||
Profile: "line_a",
|
||||
Overlays: []string{"night_relaxed"},
|
||||
ConfigID: "preview",
|
||||
ConfigVersion: "v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
|
||||
t.Fatalf("unmarshal render result: %v", err)
|
||||
}
|
||||
metadata, _ := doc["metadata"].(map[string]any)
|
||||
if got := stringValue(metadata["profile"]); got != "line_a" {
|
||||
t.Fatalf("expected sqlite profile metadata, got %#v", metadata)
|
||||
}
|
||||
names, _ := metadata["overlays"].([]any)
|
||||
if len(names) != 1 || stringValue(names[0]) != "night_relaxed" {
|
||||
t.Fatalf("expected sqlite overlay metadata, got %#v", metadata["overlays"])
|
||||
}
|
||||
templates, _ := doc["templates"].(map[string]any)
|
||||
renderedTemplate, _ := templates["std_service_test_stream__cam1"].(map[string]any)
|
||||
nodes, _ := renderedTemplate["nodes"].([]any)
|
||||
inputNode, _ := nodes[0].(map[string]any)
|
||||
if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
|
||||
t.Fatalf("expected expanded input url, got %#v", inputNode)
|
||||
}
|
||||
instances, _ := doc["instances"].([]any)
|
||||
instance, _ := instances[0].(map[string]any)
|
||||
params, _ := instance["params"].(map[string]any)
|
||||
if got := boolValue(params["debug"], false); !got {
|
||||
t.Fatalf("expected overlay params to merge into runtime instance, got %#v", instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) {
|
||||
svc := NewConfigPreviewService(&config.Config{})
|
||||
err := svc.SaveProfileEditor(ConfigProfileEditor{
|
||||
Name: "line_a",
|
||||
Instances: []ConfigProfileInstanceEditor{{
|
||||
Name: "cam1",
|
||||
Template: "helmet",
|
||||
VideoSourceRef: "gate_cam_01",
|
||||
}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "asset repository is not configured") {
|
||||
t.Fatalf("expected asset repository error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceResolveSceneBindings(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
resolved, err := svc.resolveSceneBindings(map[string]any{
|
||||
"name": "line_a",
|
||||
"instances": []any{
|
||||
map[string]any{
|
||||
"name": "cam1",
|
||||
"template": "std_workshop_face_recognition_shoe_alarm",
|
||||
"input_bindings": map[string]any{
|
||||
"video_input_main": map[string]any{
|
||||
"video_source_ref": "gate_cam_01",
|
||||
},
|
||||
},
|
||||
"service_bindings": map[string]any{
|
||||
"object_storage_main": map[string]any{
|
||||
"service_ref": "minio_main",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSceneBindings: %v", err)
|
||||
}
|
||||
instances, _ := resolved["instances"].([]any)
|
||||
instanceMap, _ := instances[0].(map[string]any)
|
||||
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
|
||||
videoInput, _ := inputBindings["video_input_main"].(map[string]any)
|
||||
resolvedInput, _ := videoInput["resolved"].(map[string]any)
|
||||
if got := stringValue(resolvedInput["url"]); got != "rtsp://10.0.0.1/live" {
|
||||
t.Fatalf("expected resolved input url, got %#v", resolvedInput)
|
||||
}
|
||||
serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
|
||||
objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
|
||||
resolvedService, _ := objectStorage["resolved"].(map[string]any)
|
||||
if got := stringValue(resolvedService["bucket"]); got != "myminio" {
|
||||
t.Fatalf("expected resolved service binding, got %#v", resolvedService)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
415
internal/service/config_runtime_render.go
Normal file
415
internal/service/config_runtime_render.go
Normal file
@ -0,0 +1,415 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var slotTokenRE = regexp.MustCompile(`^\$\{slot:([A-Za-z0-9_.-]+)\.([A-Za-z0-9_.-]+)\}$`)
|
||||
|
||||
func renderRuntimeConfig(templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, metadata map[string]any) (map[string]any, error) {
|
||||
tplName, err := runtimeTemplateName(templateRaw, templatePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instances, err := runtimeProfileInstances(profileRaw, tplName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instances, err = mergeRuntimeTemplateParams(instances, runtimeTemplateParams(templateRaw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renderedTemplates := map[string]any{}
|
||||
renderedInstances := make([]any, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
boundName, boundTemplate, renderedInstance, err := renderRuntimeSceneInstance(templateRaw, templatePath, instance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
renderedTemplates[boundName] = boundTemplate
|
||||
renderedInstances = append(renderedInstances, renderedInstance)
|
||||
}
|
||||
|
||||
root := map[string]any{
|
||||
"templates": renderedTemplates,
|
||||
"instances": renderedInstances,
|
||||
}
|
||||
for _, key := range []string{"global", "queue"} {
|
||||
if value, ok := profileRaw[key]; ok {
|
||||
root[key] = deepCopyAny(value)
|
||||
}
|
||||
}
|
||||
|
||||
for _, overlay := range overlays {
|
||||
var err error
|
||||
root, err = applyRuntimeOverlay(root, overlay.Raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
root["metadata"] = buildRuntimeMetadata(tplName, templatePath, profileRaw, profilePath, overlays, root, metadata)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
type runtimeOverlayInput struct {
|
||||
Name string
|
||||
Path string
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
func runtimeTemplateName(templateRaw map[string]any, templatePath string) (string, error) {
|
||||
name := strings.TrimSpace(firstString(templateRaw["name"], strings.TrimSuffix(filepath.Base(templatePath), filepath.Ext(templatePath))))
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("%s: template name is empty", templatePath)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func runtimeTemplateBody(templateRaw map[string]any) (map[string]any, error) {
|
||||
body := templateRaw
|
||||
if nested, ok := templateRaw["template"].(map[string]any); ok {
|
||||
body = nested
|
||||
}
|
||||
nodes, hasNodes := body["nodes"].([]any)
|
||||
edges, hasEdges := body["edges"].([]any)
|
||||
if !hasNodes || !hasEdges {
|
||||
return nil, fmt.Errorf("template body must contain nodes[] and edges[]")
|
||||
}
|
||||
out := map[string]any{
|
||||
"nodes": deepCopyAny(nodes),
|
||||
"edges": deepCopyAny(edges),
|
||||
}
|
||||
if executor, ok := body["executor"]; ok {
|
||||
out["executor"] = deepCopyAny(executor)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func runtimeTemplateParams(templateRaw map[string]any) map[string]any {
|
||||
params, _ := templateRaw["params"].(map[string]any)
|
||||
if params == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return cloneMap(params)
|
||||
}
|
||||
|
||||
func runtimeProfileInstances(profileRaw map[string]any, tplName string) ([]map[string]any, error) {
|
||||
if items, ok := profileRaw["instances"].([]any); ok {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
instance, _ := item.(map[string]any)
|
||||
if instance == nil {
|
||||
return nil, fmt.Errorf("profile.instances entries must be objects")
|
||||
}
|
||||
cloned := deepCopyMap(instance)
|
||||
if strings.TrimSpace(stringValue(cloned["template"])) == "" {
|
||||
cloned["template"] = tplName
|
||||
}
|
||||
out = append(out, cloned)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
name := strings.TrimSpace(stringValue(profileRaw["name"]))
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("profile must contain name or instances[]")
|
||||
}
|
||||
instance := map[string]any{
|
||||
"name": name,
|
||||
"template": tplName,
|
||||
}
|
||||
if params, ok := profileRaw["params"].(map[string]any); ok && len(params) > 0 {
|
||||
instance["params"] = deepCopyMap(params)
|
||||
}
|
||||
if override, ok := profileRaw["override"].(map[string]any); ok && len(override) > 0 {
|
||||
instance["override"] = deepCopyMap(override)
|
||||
}
|
||||
return []map[string]any{instance}, nil
|
||||
}
|
||||
|
||||
func mergeRuntimeTemplateParams(instances []map[string]any, sharedParams map[string]any) ([]map[string]any, error) {
|
||||
if len(sharedParams) == 0 {
|
||||
return instances, nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(instances))
|
||||
for _, item := range instances {
|
||||
inst := deepCopyMap(item)
|
||||
params, _ := inst["params"].(map[string]any)
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
inst["params"] = deepMergeMap(sharedParams, params)
|
||||
out = append(out, inst)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string, instance map[string]any) (string, map[string]any, map[string]any, error) {
|
||||
instanceName := strings.TrimSpace(stringValue(instance["name"]))
|
||||
if instanceName == "" {
|
||||
return "", nil, nil, fmt.Errorf("scene instance name is required")
|
||||
}
|
||||
tplName, err := runtimeTemplateName(templateRaw, templatePath)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
boundName := tplName + "__" + instanceName
|
||||
context, err := buildRuntimeBindingContext(instance)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
templateBody, err := runtimeTemplateBody(templateRaw)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
renderedTemplate, _ := renderedTemplateAny.(map[string]any)
|
||||
renderedInstance := map[string]any{
|
||||
"name": instanceName,
|
||||
"template": boundName,
|
||||
}
|
||||
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
|
||||
renderedInstance["scene_meta"] = deepCopyMap(sceneMeta)
|
||||
}
|
||||
if params, ok := instance["params"].(map[string]any); ok && len(params) > 0 {
|
||||
renderedInstance["params"] = deepCopyMap(params)
|
||||
}
|
||||
return boundName, renderedTemplate, renderedInstance, nil
|
||||
}
|
||||
|
||||
func buildRuntimeBindingContext(instance map[string]any) (map[string]any, error) {
|
||||
context := map[string]any{}
|
||||
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
|
||||
context["scene"] = deepCopyMap(sceneMeta)
|
||||
}
|
||||
for _, groupName := range []string{"input_bindings", "service_bindings", "output_bindings"} {
|
||||
group, _ := instance[groupName].(map[string]any)
|
||||
for slotName, raw := range group {
|
||||
entry, _ := raw.(map[string]any)
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("binding entry must be an object")
|
||||
}
|
||||
context[slotName] = resolvedRuntimeBindingValue(entry)
|
||||
}
|
||||
}
|
||||
return context, nil
|
||||
}
|
||||
|
||||
func resolvedRuntimeBindingValue(entry map[string]any) map[string]any {
|
||||
if resolved, ok := entry["resolved"].(map[string]any); ok && resolved != nil {
|
||||
return deepCopyMap(resolved)
|
||||
}
|
||||
return deepCopyMap(entry)
|
||||
}
|
||||
|
||||
func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
expanded, err := expandRuntimeSlotTokens(item, context)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = expanded
|
||||
}
|
||||
return out, nil
|
||||
case []any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
expanded, err := expandRuntimeSlotTokens(item, context)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, expanded)
|
||||
}
|
||||
return out, nil
|
||||
case string:
|
||||
match := slotTokenRE.FindStringSubmatch(strings.TrimSpace(typed))
|
||||
if len(match) != 3 {
|
||||
return typed, nil
|
||||
}
|
||||
slotValues, _ := context[match[1]].(map[string]any)
|
||||
if slotValues == nil {
|
||||
return nil, fmt.Errorf("required slot '%s' is not bound", match[1])
|
||||
}
|
||||
fieldValue, ok := slotValues[match[2]]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2])
|
||||
}
|
||||
return deepCopyAny(fieldValue), nil
|
||||
default:
|
||||
return deepCopyAny(value), nil
|
||||
}
|
||||
}
|
||||
|
||||
func applyRuntimeOverlay(root map[string]any, overlay map[string]any) (map[string]any, error) {
|
||||
out := deepCopyMap(root)
|
||||
for _, key := range []string{"global", "queue", "templates"} {
|
||||
if value, ok := overlay[key]; ok {
|
||||
out[key] = deepMergeAny(out[key], value)
|
||||
}
|
||||
}
|
||||
if rawPatches, ok := overlay["instance_overrides"]; ok {
|
||||
patches, _ := rawPatches.(map[string]any)
|
||||
if patches == nil {
|
||||
return nil, fmt.Errorf("overlay.instance_overrides must be an object")
|
||||
}
|
||||
instances, _ := out["instances"].([]any)
|
||||
mergedInstances := make([]any, 0, len(instances))
|
||||
for _, item := range instances {
|
||||
instance, _ := item.(map[string]any)
|
||||
merged := deepCopyMap(instance)
|
||||
if patch, ok := patches["*"].(map[string]any); ok {
|
||||
merged = mergeRuntimeInstancePatch(merged, patch)
|
||||
}
|
||||
name := stringValue(merged["name"])
|
||||
if patch, ok := patches[name].(map[string]any); ok {
|
||||
merged = mergeRuntimeInstancePatch(merged, patch)
|
||||
}
|
||||
mergedInstances = append(mergedInstances, merged)
|
||||
}
|
||||
out["instances"] = mergedInstances
|
||||
}
|
||||
if rawInstances, ok := overlay["instances"]; ok {
|
||||
patchList, _ := rawInstances.([]any)
|
||||
if patchList == nil {
|
||||
return nil, fmt.Errorf("overlay.instances must be an array")
|
||||
}
|
||||
instances, _ := out["instances"].([]any)
|
||||
byName := map[string]int{}
|
||||
for i, item := range instances {
|
||||
instance, _ := item.(map[string]any)
|
||||
byName[stringValue(instance["name"])] = i
|
||||
}
|
||||
for _, item := range patchList {
|
||||
patch, _ := item.(map[string]any)
|
||||
name := stringValue(patch["name"])
|
||||
if patch == nil || name == "" {
|
||||
return nil, fmt.Errorf("overlay.instances entries must be objects with name")
|
||||
}
|
||||
idx, ok := byName[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("overlay instance not found in profile: %s", name)
|
||||
}
|
||||
instance, _ := instances[idx].(map[string]any)
|
||||
instances[idx] = mergeRuntimeInstancePatch(instance, patch)
|
||||
}
|
||||
out["instances"] = instances
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) map[string]any {
|
||||
merged := deepCopyMap(instance)
|
||||
if params, ok := patch["params"].(map[string]any); ok {
|
||||
existing, _ := merged["params"].(map[string]any)
|
||||
merged["params"] = deepMergeMap(existing, params)
|
||||
}
|
||||
if override, ok := patch["override"].(map[string]any); ok {
|
||||
existing, _ := merged["override"].(map[string]any)
|
||||
merged["override"] = deepMergeMap(existing, override)
|
||||
}
|
||||
for key, value := range patch {
|
||||
if key == "name" || key == "template" || key == "params" || key == "override" {
|
||||
continue
|
||||
}
|
||||
merged[key] = deepMergeAny(merged[key], value)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func buildRuntimeMetadata(templateName string, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, root map[string]any, metadata map[string]any) map[string]any {
|
||||
instanceNames := make([]string, 0)
|
||||
instanceDisplayNames := make([]string, 0)
|
||||
instances, _ := root["instances"].([]any)
|
||||
for _, item := range instances {
|
||||
instance, _ := item.(map[string]any)
|
||||
if name := strings.TrimSpace(stringValue(instance["name"])); name != "" {
|
||||
instanceNames = append(instanceNames, name)
|
||||
}
|
||||
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok {
|
||||
if displayName := strings.TrimSpace(stringValue(sceneMeta["display_name"])); displayName != "" {
|
||||
instanceDisplayNames = append(instanceDisplayNames, displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
overlayNames := make([]any, 0, len(overlays))
|
||||
overlayPaths := make([]any, 0, len(overlays))
|
||||
for _, overlay := range overlays {
|
||||
overlayNames = append(overlayNames, overlay.Name)
|
||||
overlayPaths = append(overlayPaths, overlay.Path)
|
||||
}
|
||||
out := map[string]any{
|
||||
"template": templateName,
|
||||
"template_path": templatePath,
|
||||
"profile": strings.TrimSpace(firstString(profileRaw["name"], strings.TrimSuffix(filepath.Base(profilePath), filepath.Ext(profilePath)))),
|
||||
"business_name": strings.TrimSpace(stringValue(profileRaw["business_name"])),
|
||||
"profile_path": profilePath,
|
||||
"instance_names": instanceNames,
|
||||
"instance_display_names": instanceDisplayNames,
|
||||
"overlays": overlayNames,
|
||||
"overlay_paths": overlayPaths,
|
||||
}
|
||||
for key, value := range metadata {
|
||||
out[key] = deepCopyAny(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deepMergeMap(base map[string]any, override map[string]any) map[string]any {
|
||||
if base == nil {
|
||||
base = map[string]any{}
|
||||
}
|
||||
return deepMergeAny(base, override).(map[string]any)
|
||||
}
|
||||
|
||||
func deepMergeAny(base any, override any) any {
|
||||
baseMap, baseIsMap := base.(map[string]any)
|
||||
overrideMap, overrideIsMap := override.(map[string]any)
|
||||
if baseIsMap && overrideIsMap {
|
||||
merged := deepCopyMap(baseMap)
|
||||
for key, value := range overrideMap {
|
||||
if existing, ok := merged[key]; ok {
|
||||
merged[key] = deepMergeAny(existing, value)
|
||||
} else {
|
||||
merged[key] = deepCopyAny(value)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
return deepCopyAny(override)
|
||||
}
|
||||
|
||||
func deepCopyMap(in map[string]any) map[string]any {
|
||||
if in == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
out, _ := deepCopyAny(in).(map[string]any)
|
||||
return out
|
||||
}
|
||||
|
||||
func deepCopyAny(value any) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -3,25 +3,20 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ConfigProfileEditor struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
BusinessName string `json:"business_name"`
|
||||
Description string `json:"description"`
|
||||
DeviceCode string `json:"device_code"`
|
||||
SiteName string `json:"site_name"`
|
||||
ObjectStorageRef string `json:"object_storage_ref"`
|
||||
TokenServiceRef string `json:"token_service_ref"`
|
||||
AlarmServiceRef string `json:"alarm_service_ref"`
|
||||
Queue ConfigProfileQueueEditor `json:"queue"`
|
||||
Instances []ConfigProfileInstanceEditor `json:"instances"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
BusinessName string `json:"business_name"`
|
||||
Description string `json:"description"`
|
||||
DeviceCode string `json:"device_code"`
|
||||
SiteName string `json:"site_name"`
|
||||
Queue ConfigProfileQueueEditor `json:"queue"`
|
||||
Instances []ConfigProfileInstanceEditor `json:"instances"`
|
||||
Raw map[string]any `json:"raw"`
|
||||
}
|
||||
|
||||
type ConfigProfileQueueEditor struct {
|
||||
@ -29,17 +24,36 @@ type ConfigProfileQueueEditor struct {
|
||||
Strategy string `json:"strategy"`
|
||||
}
|
||||
|
||||
type InputBindingEditor struct {
|
||||
VideoSourceRef string `json:"video_source_ref"`
|
||||
}
|
||||
|
||||
type ServiceBindingEditor struct {
|
||||
ServiceRef string `json:"service_ref"`
|
||||
}
|
||||
|
||||
type OutputBindingEditor struct {
|
||||
PublishHLSPath string `json:"publish_hls_path"`
|
||||
PublishRTSPPort string `json:"publish_rtsp_port"`
|
||||
PublishRTSPPath string `json:"publish_rtsp_path"`
|
||||
ChannelNo string `json:"channel_no"`
|
||||
}
|
||||
|
||||
type ConfigProfileInstanceEditor struct {
|
||||
Name string `json:"name"`
|
||||
Template string `json:"template"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RTSPURL string `json:"rtsp_url"`
|
||||
PublishHLSPath string `json:"publish_hls_path"`
|
||||
PublishRTSPPort string `json:"publish_rtsp_port"`
|
||||
PublishRTSPPath string `json:"publish_rtsp_path"`
|
||||
ChannelNo string `json:"channel_no"`
|
||||
AdvancedParams map[string]any `json:"advanced_params"`
|
||||
Delete bool `json:"delete"`
|
||||
Name string `json:"name"`
|
||||
Template string `json:"template"`
|
||||
VideoSourceRef string `json:"video_source_ref"`
|
||||
DisplayName string `json:"display_name"`
|
||||
SiteName string `json:"site_name"`
|
||||
PublishHLSPath string `json:"publish_hls_path"`
|
||||
PublishRTSPPort string `json:"publish_rtsp_port"`
|
||||
PublishRTSPPath string `json:"publish_rtsp_path"`
|
||||
ChannelNo string `json:"channel_no"`
|
||||
InputBindings map[string]InputBindingEditor `json:"input_bindings,omitempty"`
|
||||
ServiceBindings map[string]ServiceBindingEditor `json:"service_bindings,omitempty"`
|
||||
OutputBindings map[string]OutputBindingEditor `json:"output_bindings,omitempty"`
|
||||
AdvancedParams map[string]any `json:"advanced_params"`
|
||||
Delete bool `json:"delete"`
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEditor, error) {
|
||||
@ -55,17 +69,22 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit
|
||||
for _, item := range instancesRaw {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
paramsMap, _ := instanceMap["params"].(map[string]any)
|
||||
sceneMeta, _ := instanceMap["scene_meta"].(map[string]any)
|
||||
inputBindings := parseInputBindingEditors(instanceMap["input_bindings"])
|
||||
serviceBindings := parseServiceBindingEditors(instanceMap["service_bindings"])
|
||||
outputBindings := parseOutputBindingEditors(instanceMap["output_bindings"])
|
||||
if deviceCode == "" {
|
||||
deviceCode = stringValue(paramsMap["device_code"])
|
||||
deviceCode = firstString(sceneMeta["device_code"], stringValue(paramsMap["device_code"]))
|
||||
}
|
||||
if siteName == "" {
|
||||
siteName = stringValue(paramsMap["site_name"])
|
||||
siteName = firstString(sceneMeta["site_name"], stringValue(paramsMap["site_name"]))
|
||||
}
|
||||
advanced := cloneMap(paramsMap)
|
||||
for _, key := range []string{
|
||||
"display_name",
|
||||
"device_code",
|
||||
"site_name",
|
||||
"video_source_ref",
|
||||
"rtsp_url",
|
||||
"publish_hls_path",
|
||||
"publish_rtsp_port",
|
||||
@ -80,25 +99,26 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit
|
||||
instances = append(instances, ConfigProfileInstanceEditor{
|
||||
Name: stringValue(instanceMap["name"]),
|
||||
Template: stringValue(instanceMap["template"]),
|
||||
DisplayName: stringValue(paramsMap["display_name"]),
|
||||
RTSPURL: stringValue(paramsMap["rtsp_url"]),
|
||||
PublishHLSPath: stringValue(paramsMap["publish_hls_path"]),
|
||||
PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]),
|
||||
PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]),
|
||||
ChannelNo: stringValue(paramsMap["channel_no"]),
|
||||
VideoSourceRef: inputBindingRef(inputBindings, "video_input_main"),
|
||||
DisplayName: stringValue(sceneMeta["display_name"]),
|
||||
SiteName: stringValue(sceneMeta["site_name"]),
|
||||
PublishHLSPath: outputBindingValue(outputBindings, "stream_output_main", "publish_hls_path"),
|
||||
PublishRTSPPort: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_port"),
|
||||
PublishRTSPPath: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_path"),
|
||||
ChannelNo: outputBindingValue(outputBindings, "stream_output_main", "channel_no"),
|
||||
InputBindings: inputBindings,
|
||||
ServiceBindings: serviceBindings,
|
||||
OutputBindings: outputBindings,
|
||||
AdvancedParams: advanced,
|
||||
})
|
||||
}
|
||||
return &ConfigProfileEditor{
|
||||
Name: firstString(raw["name"], name),
|
||||
Path: path,
|
||||
BusinessName: stringValue(raw["business_name"]),
|
||||
Description: stringValue(raw["description"]),
|
||||
DeviceCode: deviceCode,
|
||||
SiteName: siteName,
|
||||
ObjectStorageRef: stringValue(raw["object_storage_ref"]),
|
||||
TokenServiceRef: stringValue(raw["token_service_ref"]),
|
||||
AlarmServiceRef: stringValue(raw["alarm_service_ref"]),
|
||||
Name: firstString(raw["name"], name),
|
||||
Path: path,
|
||||
BusinessName: stringValue(raw["business_name"]),
|
||||
Description: stringValue(raw["description"]),
|
||||
DeviceCode: deviceCode,
|
||||
SiteName: siteName,
|
||||
Queue: ConfigProfileQueueEditor{
|
||||
Size: valueString(queueMap["size"]),
|
||||
Strategy: stringValue(queueMap["strategy"]),
|
||||
@ -138,37 +158,48 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
|
||||
}
|
||||
seen[instanceName] = struct{}{}
|
||||
|
||||
rtspURL := strings.TrimSpace(inst.RTSPURL)
|
||||
if rtspURL == "" {
|
||||
return nil, fmt.Errorf("rtsp url is required for %s", instanceName)
|
||||
videoSourceRef := strings.TrimSpace(inputBindingRef(inst.InputBindings, "video_input_main"))
|
||||
if videoSourceRef == "" {
|
||||
videoSourceRef = strings.TrimSpace(inst.VideoSourceRef)
|
||||
}
|
||||
if videoSourceRef == "" {
|
||||
return nil, fmt.Errorf("video source is required for %s", instanceName)
|
||||
}
|
||||
|
||||
params := map[string]any{}
|
||||
setString(params, "display_name", inst.DisplayName)
|
||||
setString(params, "device_code", editor.DeviceCode)
|
||||
setString(params, "site_name", editor.SiteName)
|
||||
setString(params, "rtsp_url", rtspURL)
|
||||
setString(params, "publish_hls_path", inst.PublishHLSPath)
|
||||
setString(params, "publish_rtsp_path", inst.PublishRTSPPath)
|
||||
setString(params, "channel_no", inst.ChannelNo)
|
||||
|
||||
if port := strings.TrimSpace(inst.PublishRTSPPort); port != "" {
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish rtsp port must be a number for %s", instanceName)
|
||||
}
|
||||
params["publish_rtsp_port"] = value
|
||||
}
|
||||
|
||||
for key, value := range cloneMap(inst.AdvancedParams) {
|
||||
params[key] = value
|
||||
}
|
||||
|
||||
instance := map[string]any{
|
||||
"name": instanceName,
|
||||
"params": params,
|
||||
"name": instanceName,
|
||||
}
|
||||
if len(params) > 0 {
|
||||
instance["params"] = params
|
||||
}
|
||||
setString(instance, "template", inst.Template)
|
||||
sceneMeta := map[string]any{}
|
||||
setString(sceneMeta, "display_name", inst.DisplayName)
|
||||
setString(sceneMeta, "site_name", firstString(inst.SiteName, editor.SiteName))
|
||||
setString(sceneMeta, "device_code", editor.DeviceCode)
|
||||
if len(sceneMeta) > 0 {
|
||||
instance["scene_meta"] = sceneMeta
|
||||
}
|
||||
inputBindings := buildInputBindingDocument(inst)
|
||||
if len(inputBindings) > 0 {
|
||||
instance["input_bindings"] = inputBindings
|
||||
}
|
||||
serviceBindings := buildServiceBindingDocument(inst)
|
||||
if len(serviceBindings) > 0 {
|
||||
instance["service_bindings"] = serviceBindings
|
||||
}
|
||||
outputBindings, err := buildOutputBindingDocument(inst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(outputBindings) > 0 {
|
||||
instance["output_bindings"] = outputBindings
|
||||
}
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
if len(instances) == 0 {
|
||||
@ -181,10 +212,6 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
|
||||
}
|
||||
setString(doc, "business_name", editor.BusinessName)
|
||||
setString(doc, "description", editor.Description)
|
||||
setString(doc, "object_storage_ref", editor.ObjectStorageRef)
|
||||
setString(doc, "token_service_ref", editor.TokenServiceRef)
|
||||
setString(doc, "alarm_service_ref", editor.AlarmServiceRef)
|
||||
|
||||
queue := map[string]any{}
|
||||
if size := strings.TrimSpace(editor.Queue.Size); size != "" {
|
||||
value, err := strconv.Atoi(size)
|
||||
@ -219,12 +246,7 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
root := s.mediaRepoRoot()
|
||||
if root == "" {
|
||||
return fmt.Errorf("media repo path is not configured")
|
||||
}
|
||||
path := filepath.Join(root, "configs", "profiles", strings.TrimSpace(editor.Name)+".json")
|
||||
return os.WriteFile(path, body, 0o644)
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
|
||||
func setString(m map[string]any, key string, value string) {
|
||||
@ -252,3 +274,161 @@ func firstProfileTemplate(instances []ConfigProfileInstanceEditor) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseInputBindingEditors(raw any) map[string]InputBindingEditor {
|
||||
items, _ := raw.(map[string]any)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]InputBindingEditor{}
|
||||
for key, value := range items {
|
||||
entry, _ := value.(map[string]any)
|
||||
out[key] = InputBindingEditor{
|
||||
VideoSourceRef: stringValue(entry["video_source_ref"]),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseServiceBindingEditors(raw any) map[string]ServiceBindingEditor {
|
||||
items, _ := raw.(map[string]any)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]ServiceBindingEditor{}
|
||||
for key, value := range items {
|
||||
entry, _ := value.(map[string]any)
|
||||
out[key] = ServiceBindingEditor{
|
||||
ServiceRef: stringValue(entry["service_ref"]),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseOutputBindingEditors(raw any) map[string]OutputBindingEditor {
|
||||
items, _ := raw.(map[string]any)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]OutputBindingEditor{}
|
||||
for key, value := range items {
|
||||
entry, _ := value.(map[string]any)
|
||||
out[key] = OutputBindingEditor{
|
||||
PublishHLSPath: stringValue(entry["publish_hls_path"]),
|
||||
PublishRTSPPort: valueString(entry["publish_rtsp_port"]),
|
||||
PublishRTSPPath: stringValue(entry["publish_rtsp_path"]),
|
||||
ChannelNo: stringValue(entry["channel_no"]),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func inputBindingRef(bindings map[string]InputBindingEditor, slot string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(bindings[slot].VideoSourceRef)
|
||||
}
|
||||
|
||||
func outputBindingValue(bindings map[string]OutputBindingEditor, slot string, field string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
item, ok := bindings[slot]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch field {
|
||||
case "publish_hls_path":
|
||||
return strings.TrimSpace(item.PublishHLSPath)
|
||||
case "publish_rtsp_port":
|
||||
return strings.TrimSpace(item.PublishRTSPPort)
|
||||
case "publish_rtsp_path":
|
||||
return strings.TrimSpace(item.PublishRTSPPath)
|
||||
case "channel_no":
|
||||
return strings.TrimSpace(item.ChannelNo)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildInputBindingDocument(inst ConfigProfileInstanceEditor) map[string]any {
|
||||
out := map[string]any{}
|
||||
for key, value := range inst.InputBindings {
|
||||
entry := map[string]any{}
|
||||
setString(entry, "video_source_ref", value.VideoSourceRef)
|
||||
if len(entry) > 0 {
|
||||
out[key] = entry
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(inst.VideoSourceRef) != "" {
|
||||
if _, ok := out["video_input_main"]; !ok {
|
||||
out["video_input_main"] = map[string]any{"video_source_ref": strings.TrimSpace(inst.VideoSourceRef)}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildServiceBindingDocument(inst ConfigProfileInstanceEditor) map[string]any {
|
||||
out := map[string]any{}
|
||||
for key, value := range inst.ServiceBindings {
|
||||
entry := map[string]any{}
|
||||
setString(entry, "service_ref", value.ServiceRef)
|
||||
if len(entry) > 0 {
|
||||
out[key] = entry
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
for key, value := range inst.OutputBindings {
|
||||
entry, err := outputBindingEntry(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(entry) > 0 {
|
||||
out[key] = entry
|
||||
}
|
||||
}
|
||||
if _, ok := out["stream_output_main"]; !ok {
|
||||
entry, err := outputBindingEntry(OutputBindingEditor{
|
||||
PublishHLSPath: inst.PublishHLSPath,
|
||||
PublishRTSPPort: inst.PublishRTSPPort,
|
||||
PublishRTSPPath: inst.PublishRTSPPath,
|
||||
ChannelNo: inst.ChannelNo,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(entry) > 0 {
|
||||
out["stream_output_main"] = entry
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) {
|
||||
entry := map[string]any{}
|
||||
setString(entry, "publish_hls_path", value.PublishHLSPath)
|
||||
setString(entry, "publish_rtsp_path", value.PublishRTSPPath)
|
||||
setString(entry, "channel_no", value.ChannelNo)
|
||||
if port := strings.TrimSpace(value.PublishRTSPPort); port != "" {
|
||||
parsed, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish rtsp port must be a number")
|
||||
}
|
||||
entry["publish_rtsp_port"] = parsed
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
75
internal/service/standard_templates.go
Normal file
75
internal/service/standard_templates.go
Normal file
@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"3588AdminBackend/internal/storage"
|
||||
)
|
||||
|
||||
func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int, error) {
|
||||
if repo == nil {
|
||||
return 0, fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
dir = filepath.Clean(strings.TrimSpace(dir))
|
||||
if dir == "" {
|
||||
return 0, fmt.Errorf("standard template dir is empty")
|
||||
}
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
imported := 0
|
||||
for _, file := range files {
|
||||
if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, file.Name())
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return imported, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
name := strings.TrimSpace(firstString(raw["name"], strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))))
|
||||
if name == "" {
|
||||
return imported, fmt.Errorf("%s: template name is empty", path)
|
||||
}
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return imported, fmt.Errorf("%s: invalid template name %q: %w", path, name, err)
|
||||
}
|
||||
if !isStandardTemplateName(name) {
|
||||
return imported, fmt.Errorf("%s: standard template name must start with std_", path)
|
||||
}
|
||||
existing, err := repo.GetTemplate(name)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
if existing != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(stringValue(raw["source"])) == "" {
|
||||
raw["source"] = "standard"
|
||||
}
|
||||
body, err = marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil {
|
||||
return imported, err
|
||||
}
|
||||
imported++
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
67
internal/service/template_slots.go
Normal file
67
internal/service/template_slots.go
Normal file
@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import "fmt"
|
||||
|
||||
type TemplateSlotGroup struct {
|
||||
Inputs []TemplateSlot `json:"inputs"`
|
||||
Services []TemplateSlot `json:"services"`
|
||||
Outputs []TemplateSlot `json:"outputs"`
|
||||
}
|
||||
|
||||
type TemplateSlot struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func parseTemplateSlots(raw map[string]any) (TemplateSlotGroup, error) {
|
||||
group := TemplateSlotGroup{}
|
||||
slotsMap, _ := raw["slots"].(map[string]any)
|
||||
if slotsMap == nil {
|
||||
return group, nil
|
||||
}
|
||||
var err error
|
||||
if group.Inputs, err = parseTemplateSlotList(slotsMap["inputs"]); err != nil {
|
||||
return TemplateSlotGroup{}, fmt.Errorf("parse input slots: %w", err)
|
||||
}
|
||||
if group.Services, err = parseTemplateSlotList(slotsMap["services"]); err != nil {
|
||||
return TemplateSlotGroup{}, fmt.Errorf("parse service slots: %w", err)
|
||||
}
|
||||
if group.Outputs, err = parseTemplateSlotList(slotsMap["outputs"]); err != nil {
|
||||
return TemplateSlotGroup{}, fmt.Errorf("parse output slots: %w", err)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func parseTemplateSlotList(raw any) ([]TemplateSlot, error) {
|
||||
items, _ := raw.([]any)
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]TemplateSlot, 0, len(items))
|
||||
for _, item := range items {
|
||||
slotMap, _ := item.(map[string]any)
|
||||
if slotMap == nil {
|
||||
return nil, fmt.Errorf("slot entry must be an object")
|
||||
}
|
||||
slot := TemplateSlot{
|
||||
Name: stringValue(slotMap["name"]),
|
||||
Type: stringValue(slotMap["type"]),
|
||||
Required: boolValue(slotMap["required"], false),
|
||||
Description: stringValue(slotMap["description"]),
|
||||
}
|
||||
if slot.Name == "" {
|
||||
return nil, fmt.Errorf("slot name is required")
|
||||
}
|
||||
if err := validateConfigName(slot.Name); err != nil {
|
||||
return nil, fmt.Errorf("invalid slot name %q: %w", slot.Name, err)
|
||||
}
|
||||
if slot.Type == "" {
|
||||
return nil, fmt.Errorf("slot type is required for %s", slot.Name)
|
||||
}
|
||||
out = append(out, slot)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
34
internal/service/template_slots_test.go
Normal file
34
internal/service/template_slots_test.go
Normal file
@ -0,0 +1,34 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTemplateSlots(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"slots": map[string]any{
|
||||
"inputs": []any{
|
||||
map[string]any{"name": "video_input_main", "type": "video_source", "required": true},
|
||||
},
|
||||
"services": []any{
|
||||
map[string]any{"name": "object_storage_main", "type": "object_storage", "required": true},
|
||||
},
|
||||
"outputs": []any{
|
||||
map[string]any{"name": "stream_output_main", "type": "stream_publish", "required": true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
slots, err := parseTemplateSlots(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTemplateSlots: %v", err)
|
||||
}
|
||||
if len(slots.Inputs) != 1 || slots.Inputs[0].Name != "video_input_main" {
|
||||
t.Fatalf("unexpected input slots: %#v", slots.Inputs)
|
||||
}
|
||||
if len(slots.Services) != 1 || slots.Services[0].Type != "object_storage" {
|
||||
t.Fatalf("unexpected service slots: %#v", slots.Services)
|
||||
}
|
||||
if len(slots.Outputs) != 1 || slots.Outputs[0].Name != "stream_output_main" {
|
||||
t.Fatalf("unexpected output slots: %#v", slots.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,16 @@ type IntegrationServiceRecord struct {
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type VideoSourceRecord struct {
|
||||
Name string
|
||||
SourceType string
|
||||
Area string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type AssetsRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
@ -80,6 +90,24 @@ ON CONFLICT(name) DO UPDATE SET
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) SaveVideoSource(name string, sourceType string, area string, description string, bodyJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO video_sources(name, source_type, area, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM video_sources WHERE name = ?), ?), ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
source_type=excluded.source_type,
|
||||
area=excluded.area,
|
||||
description=excluded.description,
|
||||
body_json=excluded.body_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, name, sourceType, area, description, bodyJSON, name, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) {
|
||||
return r.listAssets("templates")
|
||||
}
|
||||
@ -117,6 +145,31 @@ ORDER BY updated_at DESC, name ASC
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) ListVideoSources() ([]VideoSourceRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT name, source_type, area, description, body_json, created_at, updated_at
|
||||
FROM video_sources
|
||||
ORDER BY updated_at DESC, name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []VideoSourceRecord
|
||||
for rows.Next() {
|
||||
var item VideoSourceRecord
|
||||
if err := rows.Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) {
|
||||
return r.getAsset("templates", name)
|
||||
}
|
||||
@ -148,6 +201,25 @@ WHERE name = ?
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) GetVideoSource(name string) (*VideoSourceRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var item VideoSourceRecord
|
||||
err := r.db.QueryRow(`
|
||||
SELECT name, source_type, area, description, body_json, created_at, updated_at
|
||||
FROM video_sources
|
||||
WHERE name = ?
|
||||
`, name).Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteTemplate(name string) error {
|
||||
return r.deleteAsset("templates", name)
|
||||
}
|
||||
@ -160,6 +232,14 @@ func (r *AssetsRepo) DeleteIntegrationService(name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteVideoSource(name string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(`DELETE FROM video_sources WHERE name = ?`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
@ -210,7 +290,7 @@ WHERE name = ?
|
||||
rows, err := tx.Query(`
|
||||
SELECT name, description, business_name, body_json
|
||||
FROM profiles
|
||||
WHERE template_name = ? OR body_json LIKE ?
|
||||
WHERE primary_template_name = ? OR body_json LIKE ?
|
||||
`, oldName, "%\"template\":\""+oldName+"\"%")
|
||||
if err != nil {
|
||||
return err
|
||||
@ -244,7 +324,7 @@ WHERE template_name = ? OR body_json LIKE ?
|
||||
for _, item := range updates {
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE profiles
|
||||
SET template_name = ?, body_json = ?, updated_at = ?
|
||||
SET primary_template_name = ?, body_json = ?, updated_at = ?
|
||||
WHERE name = ?
|
||||
`, newName, item.bodyJSON, now, item.name); err != nil {
|
||||
return err
|
||||
@ -279,10 +359,10 @@ ON CONFLICT(name) DO UPDATE SET
|
||||
return err
|
||||
case "profiles":
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at)
|
||||
INSERT INTO profiles(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM profiles WHERE name = ?), ?), ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
template_name=excluded.template_name,
|
||||
primary_template_name=excluded.primary_template_name,
|
||||
business_name=excluded.business_name,
|
||||
description=excluded.description,
|
||||
body_json=excluded.body_json,
|
||||
@ -305,7 +385,7 @@ ORDER BY updated_at DESC, name ASC
|
||||
`
|
||||
if table == "profiles" {
|
||||
query = `
|
||||
SELECT name, description, body_json, created_at, updated_at, template_name, business_name
|
||||
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
|
||||
FROM profiles
|
||||
ORDER BY updated_at DESC, name ASC
|
||||
`
|
||||
@ -338,7 +418,7 @@ WHERE name = ?
|
||||
`
|
||||
if table == "profiles" {
|
||||
query = `
|
||||
SELECT name, description, body_json, created_at, updated_at, template_name, business_name
|
||||
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
|
||||
FROM profiles
|
||||
WHERE name = ?
|
||||
`
|
||||
|
||||
@ -143,3 +143,44 @@ func TestAssetsRepoStoresIntegrationServiceDisabledState(t *testing.T) {
|
||||
t.Fatalf("expected body_json payload preserved, got %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsRepoStoresVideoSource(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
repo := NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
|
||||
records, err := repo.ListVideoSources()
|
||||
if err != nil {
|
||||
t.Fatalf("ListVideoSources: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected one video source, got %#v", records)
|
||||
}
|
||||
if records[0].SourceType != "rtsp" || records[0].Area != "东门入口" {
|
||||
t.Fatalf("unexpected video source record: %#v", records[0])
|
||||
}
|
||||
|
||||
record, err := repo.GetVideoSource("gate_cam_01")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVideoSource: %v", err)
|
||||
}
|
||||
if record == nil {
|
||||
t.Fatal("expected video source record")
|
||||
}
|
||||
if record.SourceType != "rtsp" || record.Area != "东门入口" {
|
||||
t.Fatalf("unexpected video source fetch: %#v", record)
|
||||
}
|
||||
if !strings.Contains(record.BodyJSON, `"resolution":"1080p"`) {
|
||||
t.Fatalf("expected body_json payload preserved, got %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package storage
|
||||
|
||||
import "database/sql"
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const schema001 = `
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
@ -14,7 +17,7 @@ CREATE TABLE IF NOT EXISTS templates (
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
template_name TEXT NOT NULL DEFAULT '',
|
||||
primary_template_name TEXT NOT NULL DEFAULT '',
|
||||
business_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_json TEXT NOT NULL,
|
||||
@ -39,6 +42,16 @@ CREATE TABLE IF NOT EXISTS integration_services (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS video_sources (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
source_type TEXT NOT NULL DEFAULT '',
|
||||
area TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
@ -93,6 +106,82 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
`
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
_, err := db.Exec(schema001)
|
||||
return err
|
||||
if _, err := db.Exec(schema001); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateProfilePrimaryTemplateName(db)
|
||||
}
|
||||
|
||||
func migrateProfilePrimaryTemplateName(db *sql.DB) error {
|
||||
hasPrimary, err := hasColumn(db, "profiles", "primary_template_name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasPrimary {
|
||||
return nil
|
||||
}
|
||||
hasLegacy, err := hasColumn(db, "profiles", "template_name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasLegacy {
|
||||
return fmt.Errorf("profiles table is missing both primary_template_name and template_name")
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE profiles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
primary_template_name TEXT NOT NULL DEFAULT '',
|
||||
business_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO profiles(id, name, primary_template_name, business_name, description, body_json, created_at, updated_at)
|
||||
SELECT id, name, template_name, business_name, description, body_json, created_at, updated_at
|
||||
FROM profiles_legacy_template_name
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func hasColumn(db *sql.DB, table string, column string) (bool, error) {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dfltValue sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@ -18,6 +19,7 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) {
|
||||
"profiles",
|
||||
"overlays",
|
||||
"integration_services",
|
||||
"video_sources",
|
||||
"devices",
|
||||
"device_config_state",
|
||||
"tasks",
|
||||
@ -33,3 +35,58 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreMigratesLegacyProfileTemplateColumn(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "app.db")
|
||||
legacyDB, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy sqlite: %v", err)
|
||||
}
|
||||
_, err = legacyDB.Exec(`
|
||||
CREATE TABLE profiles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
template_name TEXT NOT NULL DEFAULT '',
|
||||
business_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at)
|
||||
VALUES('line_a', 'helmet', 'gate', 'desc', '{"name":"line_a","instances":[{"name":"cam1","template":"helmet"}]}', '2026-04-29T00:00:00+08:00', '2026-04-29T00:00:00+08:00');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed legacy schema: %v", err)
|
||||
}
|
||||
_ = legacyDB.Close()
|
||||
|
||||
store, err := OpenSQLite(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite migrate legacy: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
hasOld, err := hasColumn(store.DB(), "profiles", "template_name")
|
||||
if err != nil {
|
||||
t.Fatalf("has old column: %v", err)
|
||||
}
|
||||
if hasOld {
|
||||
t.Fatal("expected legacy template_name column to be removed")
|
||||
}
|
||||
hasNew, err := hasColumn(store.DB(), "profiles", "primary_template_name")
|
||||
if err != nil {
|
||||
t.Fatalf("has new column: %v", err)
|
||||
}
|
||||
if !hasNew {
|
||||
t.Fatal("expected primary_template_name column to exist after migration")
|
||||
}
|
||||
repo := NewAssetsRepo(store.DB())
|
||||
profile, err := repo.GetProfile("line_a")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile after migration: %v", err)
|
||||
}
|
||||
if profile == nil || profile.TemplateName != "helmet" {
|
||||
t.Fatalf("expected migrated primary template value, got %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,8 +21,8 @@ type graphNodeTypeInfo struct {
|
||||
|
||||
func graphNodeTypesCatalog() []graphNodeTypeInfo {
|
||||
return []graphNodeTypeInfo{
|
||||
nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${rtsp_url}"}, []graphNodeParam{
|
||||
textParam("url", "RTSP 地址", "${rtsp_url}"),
|
||||
nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${slot:video_input_main.url}"}, []graphNodeParam{
|
||||
textParam("url", "RTSP 地址", "${slot:video_input_main.url}"),
|
||||
numberParam("fps", "输入帧率", "1"),
|
||||
numberParam("width", "宽度", "1"),
|
||||
numberParam("height", "高度", "1"),
|
||||
|
||||
@ -74,7 +74,14 @@ type PageData struct {
|
||||
AssetProfiles []service.ConfigProfileAsset
|
||||
AssetProfile *service.ConfigProfileAsset
|
||||
AssetProfileEditor *service.ConfigProfileEditor
|
||||
ActiveInstanceIndex int
|
||||
AssetTemplateMap map[string]service.ConfigTemplateAsset
|
||||
AssetVideoSources []service.ConfigVideoSourceAsset
|
||||
AssetVideoSource *service.ConfigVideoSourceAsset
|
||||
AssetVideoSourceEditing bool
|
||||
AssetIntegrations []service.ConfigIntegrationServiceAsset
|
||||
AssetIntegration *service.ConfigIntegrationServiceAsset
|
||||
AssetIntegrationEditing bool
|
||||
AssetOverlays []service.ConfigOverlayAsset
|
||||
AssetOverlay *service.ConfigOverlayAsset
|
||||
AssetInstanceCount int
|
||||
@ -327,6 +334,55 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
|
||||
"icon": func(name string) template.HTML {
|
||||
return template.HTML(tablerIconSVG(name))
|
||||
},
|
||||
"slotTypeLabel": func(v string) string {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "video_source":
|
||||
return "视频源"
|
||||
case "object_storage":
|
||||
return "对象存储"
|
||||
case "token_service":
|
||||
return "认证服务"
|
||||
case "alarm_service":
|
||||
return "告警服务"
|
||||
case "stream_publish":
|
||||
return "视频输出"
|
||||
default:
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
},
|
||||
"inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(bindings[slot].VideoSourceRef)
|
||||
},
|
||||
"serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(bindings[slot].ServiceRef)
|
||||
},
|
||||
"outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
item, ok := bindings[slot]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch strings.TrimSpace(field) {
|
||||
case "publish_hls_path":
|
||||
return strings.TrimSpace(item.PublishHLSPath)
|
||||
case "publish_rtsp_port":
|
||||
return strings.TrimSpace(item.PublishRTSPPort)
|
||||
case "publish_rtsp_path":
|
||||
return strings.TrimSpace(item.PublishRTSPPath)
|
||||
case "channel_no":
|
||||
return strings.TrimSpace(item.ChannelNo)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
},
|
||||
}).ParseFS(uiFS, "ui/templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -440,6 +496,8 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
r.Get("/plans/{name}/export", u.pagePlanExport)
|
||||
r.Get("/assets", u.pageAssets)
|
||||
r.Get("/assets/video-sources", u.pageAssetVideoSources)
|
||||
r.Post("/assets/video-sources", u.actionAssetVideoSourceSave)
|
||||
r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete)
|
||||
r.Get("/assets/templates", u.pageAssetTemplates)
|
||||
r.Post("/assets/templates/create", u.actionAssetTemplateCreate)
|
||||
r.Get("/assets/templates/{name}", u.pageAssetTemplate)
|
||||
@ -453,12 +511,15 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
r.Post("/assets/profiles/{name}", u.actionPlanSave)
|
||||
r.Get("/assets/profiles/{name}/export", u.pagePlanExport)
|
||||
r.Get("/assets/integrations", u.pageAssetIntegrations)
|
||||
r.Post("/assets/integrations", u.actionAssetIntegrationSave)
|
||||
r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete)
|
||||
r.Get("/assets/overlays", u.pageAssetOverlays)
|
||||
r.Get("/assets/overlays/{name}", u.pageAssetOverlay)
|
||||
r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport)
|
||||
r.Get("/audit", u.pageAudit)
|
||||
r.Get("/system", u.pageSystem)
|
||||
r.Get("/system/db-backup", u.pageSystemDBBackup)
|
||||
r.Get("/resources", u.pageResources)
|
||||
r.Post("/system/db-restore", u.actionSystemDBRestore)
|
||||
r.Get("/api/graph-node-types", u.apiGraphNodeTypes)
|
||||
r.Get("/device-config", u.pageDeviceConfig)
|
||||
@ -475,6 +536,7 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
r.Get("/devices/{id}/logs", u.pageDeviceLogs)
|
||||
r.Get("/devices/{id}/graphs", u.pageDeviceGraphs)
|
||||
r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply)
|
||||
r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply)
|
||||
r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI)
|
||||
r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly)
|
||||
r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview)
|
||||
@ -784,6 +846,35 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
|
||||
data.PersistedConfig = state
|
||||
}
|
||||
}
|
||||
if u.preview != nil {
|
||||
if profiles, err := u.preview.ListProfileAssets(); err == nil {
|
||||
data.AssetProfiles = profiles
|
||||
selectedProfile := ""
|
||||
if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" {
|
||||
selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile)
|
||||
} else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" {
|
||||
selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName)
|
||||
}
|
||||
if selectedProfile == "" && len(profiles) > 0 {
|
||||
selectedProfile = profiles[0].Name
|
||||
}
|
||||
data.SelectedProfile = selectedProfile
|
||||
for i := range profiles {
|
||||
if strings.TrimSpace(profiles[i].Name) == selectedProfile {
|
||||
data.AssetProfile = &profiles[i]
|
||||
data.SelectedTemplate = profileAssetTemplate(&profiles[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
if data.AssetProfile == nil && len(profiles) > 0 {
|
||||
data.AssetProfile = &profiles[0]
|
||||
data.SelectedProfile = profiles[0].Name
|
||||
data.SelectedTemplate = profileAssetTemplate(&profiles[0])
|
||||
}
|
||||
} else if data.Error == "" {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@ -1169,7 +1260,11 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "diagnostics", PageData{Title: "诊断", Devices: u.registry.GetDevices()})
|
||||
u.render(w, r, "diagnostics", PageData{Title: "日志审计", Devices: u.registry.GetDevices()})
|
||||
}
|
||||
|
||||
func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "resources", PageData{Title: "资源管理", Devices: u.registry.GetDevices()})
|
||||
}
|
||||
|
||||
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
|
||||
@ -1513,6 +1608,7 @@ func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
|
||||
if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
|
||||
data.SelectedTemplate = editor.Instances[0].Template
|
||||
}
|
||||
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
|
||||
} else if data.Error == "" {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
@ -1532,6 +1628,7 @@ func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
data.Title = "场景配置"
|
||||
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
|
||||
u.render(w, r, "plans", data)
|
||||
}
|
||||
|
||||
@ -1554,6 +1651,23 @@ func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(r.FormValue("add_instance")) == "1" {
|
||||
data.AssetProfileEditor = &editor
|
||||
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), len(editor.Instances)-1)
|
||||
data.Title = "场景配置"
|
||||
u.render(w, r, "plans", data)
|
||||
return
|
||||
}
|
||||
if raw := strings.TrimSpace(r.FormValue("remove_instance")); raw != "" {
|
||||
if idx, convErr := strconv.Atoi(raw); convErr == nil && idx >= 0 && idx < len(editor.Instances) {
|
||||
editor.Instances = append(editor.Instances[:idx], editor.Instances[idx+1:]...)
|
||||
}
|
||||
data.AssetProfileEditor = &editor
|
||||
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
|
||||
data.Title = "场景配置"
|
||||
u.render(w, r, "plans", data)
|
||||
return
|
||||
}
|
||||
if err := u.preview.SaveProfileEditor(editor); err != nil {
|
||||
data.Error = err.Error()
|
||||
data.Title = "场景配置"
|
||||
@ -1588,15 +1702,170 @@ func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request)
|
||||
func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("video-sources")
|
||||
data.Title = "基础配置"
|
||||
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
|
||||
if data.Error == "" {
|
||||
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
|
||||
}
|
||||
newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
|
||||
editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
|
||||
selected := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
if selected != "" {
|
||||
if item, err := u.preview.GetVideoSource(selected); err == nil {
|
||||
data.AssetVideoSource = item
|
||||
} else if data.Error == "" {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
} else if !newMode && len(data.AssetVideoSources) > 0 {
|
||||
if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil {
|
||||
data.AssetVideoSource = item
|
||||
}
|
||||
}
|
||||
if data.AssetVideoSource == nil {
|
||||
data.AssetVideoSource = &service.ConfigVideoSourceAsset{
|
||||
SourceType: "rtsp",
|
||||
SourceTypeLabel: "RTSP",
|
||||
}
|
||||
data.AssetVideoSourceEditing = true
|
||||
} else {
|
||||
data.AssetVideoSourceEditing = newMode || editMode
|
||||
}
|
||||
u.render(w, r, "assets", data)
|
||||
}
|
||||
|
||||
func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request) {
|
||||
if u.preview == nil {
|
||||
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
asset := service.ConfigVideoSourceAsset{
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
SourceType: strings.TrimSpace(r.FormValue("source_type")),
|
||||
Area: strings.TrimSpace(r.FormValue("area")),
|
||||
Description: strings.TrimSpace(r.FormValue("description")),
|
||||
Config: service.VideoSourceConfig{
|
||||
URL: strings.TrimSpace(r.FormValue("url")),
|
||||
Resolution: strings.TrimSpace(r.FormValue("resolution")),
|
||||
FrameSize: strings.TrimSpace(r.FormValue("frame_size")),
|
||||
FPS: strings.TrimSpace(r.FormValue("fps")),
|
||||
VideoFormat: strings.TrimSpace(r.FormValue("video_format")),
|
||||
FocalLength: strings.TrimSpace(r.FormValue("focal_length")),
|
||||
MountHeight: strings.TrimSpace(r.FormValue("mount_height")),
|
||||
MountAngle: strings.TrimSpace(r.FormValue("mount_angle")),
|
||||
},
|
||||
}
|
||||
if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
|
||||
data := u.assetPageData("video-sources")
|
||||
data.Title = "基础配置"
|
||||
data.Error = err.Error()
|
||||
data.AssetVideoSource = &asset
|
||||
data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType)
|
||||
data.AssetVideoSourceEditing = true
|
||||
u.render(w, r, "assets", data)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if u.preview == nil {
|
||||
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
name := chi.URLParam(r, "name")
|
||||
if err := u.preview.DeleteVideoSource(name); err != nil {
|
||||
http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("integrations")
|
||||
data.Title = "基础配置"
|
||||
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
|
||||
if data.Error == "" {
|
||||
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
|
||||
}
|
||||
newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
|
||||
editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
|
||||
selected := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
if selected != "" {
|
||||
if item, err := u.preview.GetIntegrationService(selected); err == nil {
|
||||
data.AssetIntegration = item
|
||||
} else if data.Error == "" {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
} else if !newMode && len(data.AssetIntegrations) > 0 {
|
||||
if item, err := u.preview.GetIntegrationService(data.AssetIntegrations[0].Name); err == nil {
|
||||
data.AssetIntegration = item
|
||||
}
|
||||
}
|
||||
if data.AssetIntegration == nil {
|
||||
data.AssetIntegration = &service.ConfigIntegrationServiceAsset{
|
||||
Type: "object_storage",
|
||||
TypeLabel: "对象存储",
|
||||
Enabled: true,
|
||||
ObjectStorage: &service.ObjectStorageConfig{},
|
||||
}
|
||||
data.AssetIntegrationEditing = true
|
||||
} else {
|
||||
data.AssetIntegrationEditing = newMode || editMode
|
||||
}
|
||||
u.render(w, r, "assets", data)
|
||||
}
|
||||
|
||||
func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request) {
|
||||
if u.preview == nil {
|
||||
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "true") || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "on")
|
||||
asset := service.ConfigIntegrationServiceAsset{
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
Type: strings.TrimSpace(r.FormValue("type")),
|
||||
Description: strings.TrimSpace(r.FormValue("description")),
|
||||
Enabled: enabled,
|
||||
ObjectStorage: &service.ObjectStorageConfig{
|
||||
Endpoint: strings.TrimSpace(r.FormValue("endpoint")),
|
||||
Bucket: strings.TrimSpace(r.FormValue("bucket")),
|
||||
AccessKey: strings.TrimSpace(r.FormValue("access_key")),
|
||||
SecretKey: strings.TrimSpace(r.FormValue("secret_key")),
|
||||
},
|
||||
TokenService: &service.TokenServiceConfig{
|
||||
GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")),
|
||||
Username: strings.TrimSpace(r.FormValue("username")),
|
||||
Password: strings.TrimSpace(r.FormValue("password")),
|
||||
TenantCode: strings.TrimSpace(r.FormValue("tenant_code")),
|
||||
},
|
||||
AlarmService: &service.AlarmServiceConfig{
|
||||
PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")),
|
||||
Username: strings.TrimSpace(r.FormValue("alarm_username")),
|
||||
Password: strings.TrimSpace(r.FormValue("alarm_password")),
|
||||
TenantCode: strings.TrimSpace(r.FormValue("alarm_tenant_code")),
|
||||
},
|
||||
}
|
||||
if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil {
|
||||
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if u.preview == nil {
|
||||
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
name := chi.URLParam(r, "name")
|
||||
if err := u.preview.DeleteIntegrationService(name); err != nil {
|
||||
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.assetPageData("overlays")
|
||||
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
|
||||
@ -1647,6 +1916,10 @@ func (u *UI) assetPageData(tab string) PageData {
|
||||
}
|
||||
if items, listErr := u.preview.ListTemplateAssets(); listErr == nil {
|
||||
data.AssetTemplates = items
|
||||
data.AssetTemplateMap = make(map[string]service.ConfigTemplateAsset, len(items))
|
||||
for _, item := range items {
|
||||
data.AssetTemplateMap[item.Name] = item
|
||||
}
|
||||
} else if data.Error == "" {
|
||||
data.Error = listErr.Error()
|
||||
}
|
||||
@ -1663,6 +1936,11 @@ func (u *UI) assetPageData(tab string) PageData {
|
||||
} else if data.Error == "" {
|
||||
data.Error = listErr.Error()
|
||||
}
|
||||
if items, listErr := u.preview.ListVideoSources(); listErr == nil {
|
||||
data.AssetVideoSources = items
|
||||
} else if data.Error == "" {
|
||||
data.Error = listErr.Error()
|
||||
}
|
||||
if items, listErr := u.preview.ListIntegrationServices(); listErr == nil {
|
||||
data.AssetIntegrations = items
|
||||
} else if data.Error == "" {
|
||||
@ -1690,6 +1968,31 @@ func (u *UI) profileEditorPageData(name string) (PageData, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func clampActiveInstanceIndex(count int, preferred int) int {
|
||||
if count <= 0 {
|
||||
return 0
|
||||
}
|
||||
if preferred < 0 {
|
||||
return 0
|
||||
}
|
||||
if preferred >= count {
|
||||
return count - 1
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
|
||||
func activeInstanceIndexFromValues(values url.Values) int {
|
||||
raw := strings.TrimSpace(values.Get("active_instance"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
idx, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func (u *UI) profileEditorActionData(r *http.Request, name string) (service.ConfigProfileEditor, PageData, error) {
|
||||
data, err := u.profileEditorPageData(name)
|
||||
if err != nil {
|
||||
@ -1697,13 +2000,10 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
editor := service.ConfigProfileEditor{
|
||||
Name: strings.TrimSpace(r.FormValue("profile_name")),
|
||||
BusinessName: strings.TrimSpace(r.FormValue("business_name")),
|
||||
Description: strings.TrimSpace(r.FormValue("description")),
|
||||
SiteName: strings.TrimSpace(r.FormValue("site_name")),
|
||||
ObjectStorageRef: strings.TrimSpace(r.FormValue("object_storage_ref")),
|
||||
TokenServiceRef: strings.TrimSpace(r.FormValue("token_service_ref")),
|
||||
AlarmServiceRef: strings.TrimSpace(r.FormValue("alarm_service_ref")),
|
||||
Name: strings.TrimSpace(r.FormValue("profile_name")),
|
||||
BusinessName: strings.TrimSpace(r.FormValue("business_name")),
|
||||
Description: strings.TrimSpace(r.FormValue("description")),
|
||||
SiteName: strings.TrimSpace(r.FormValue("site_name")),
|
||||
Queue: service.ConfigProfileQueueEditor{
|
||||
Size: strings.TrimSpace(r.FormValue("queue_size")),
|
||||
Strategy: strings.TrimSpace(r.FormValue("queue_strategy")),
|
||||
@ -1721,6 +2021,7 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf
|
||||
}
|
||||
data.AssetProfileEditor = &editor
|
||||
data.SelectedProfile = editor.Name
|
||||
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
|
||||
return editor, data, nil
|
||||
}
|
||||
|
||||
@ -1838,6 +2139,21 @@ func normalizeConfigName(name string) (string, error) {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func serviceVideoSourceTypeLabel(v string) string {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "rtsp":
|
||||
return "RTSP"
|
||||
case "rtmp":
|
||||
return "RTMP"
|
||||
case "file":
|
||||
return "文件"
|
||||
case "usb_camera":
|
||||
return "USB 摄像头"
|
||||
default:
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
|
||||
func compactJSON(v any) (string, error) {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@ -2177,6 +2493,104 @@ func parseAdvancedParams(raw string) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
dev, ok := u.findDevice(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
data := u.deviceDetailPageData(dev)
|
||||
req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))}
|
||||
if req.Profile == "" {
|
||||
req.Profile = data.SelectedProfile
|
||||
}
|
||||
data.SelectedProfile = req.Profile
|
||||
if req.Profile != "" && len(data.AssetProfiles) > 0 {
|
||||
for i := range data.AssetProfiles {
|
||||
if strings.TrimSpace(data.AssetProfiles[i].Name) == req.Profile {
|
||||
data.AssetProfile = &data.AssetProfiles[i]
|
||||
data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Profile == "" {
|
||||
data.Error = "请先选择场景配置"
|
||||
u.render(w, r, "device", data)
|
||||
return
|
||||
}
|
||||
if data.SelectedTemplate == "" {
|
||||
data.Error = "所选场景配置缺少可用模板,无法生成下发内容"
|
||||
u.render(w, r, "device", data)
|
||||
return
|
||||
}
|
||||
if u.tasks == nil {
|
||||
data.Error = "task service not initialized"
|
||||
u.render(w, r, "device", data)
|
||||
return
|
||||
}
|
||||
req.Template = data.SelectedTemplate
|
||||
preview, err := u.preview.Render(req)
|
||||
data.ConfigPreview = preview
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
u.render(w, r, "device", data)
|
||||
return
|
||||
}
|
||||
var configDoc any
|
||||
if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
|
||||
data.Error = "生成配置 JSON 无效: " + err.Error()
|
||||
u.render(w, r, "device", data)
|
||||
return
|
||||
}
|
||||
task, err := u.tasks.CreateTask("config_apply", []string{dev.DeviceID}, map[string]any{"config": configDoc})
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
u.render(w, r, "device", data)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
|
||||
}
|
||||
|
||||
func nextProfileInstanceName(instances []service.ConfigProfileInstanceEditor) string {
|
||||
used := make(map[string]struct{}, len(instances))
|
||||
for _, inst := range instances {
|
||||
name := strings.TrimSpace(inst.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
used[name] = struct{}{}
|
||||
}
|
||||
for i := 1; ; i++ {
|
||||
candidate := fmt.Sprintf("cam%d", i)
|
||||
if _, ok := used[candidate]; !ok {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newProfileInstanceDraft(templateName string, channelName string) service.ConfigProfileInstanceEditor {
|
||||
outputs := map[string]service.OutputBindingEditor{
|
||||
"stream_output_main": {
|
||||
PublishHLSPath: "./web/hls/" + channelName + "/index.m3u8",
|
||||
PublishRTSPPort: "8555",
|
||||
PublishRTSPPath: "/live/" + channelName,
|
||||
ChannelNo: channelName,
|
||||
},
|
||||
}
|
||||
return service.ConfigProfileInstanceEditor{
|
||||
Name: channelName,
|
||||
Template: templateName,
|
||||
PublishHLSPath: outputs["stream_output_main"].PublishHLSPath,
|
||||
PublishRTSPPort: outputs["stream_output_main"].PublishRTSPPort,
|
||||
PublishRTSPPath: outputs["stream_output_main"].PublishRTSPPath,
|
||||
ChannelNo: outputs["stream_output_main"].ChannelNo,
|
||||
OutputBindings: outputs,
|
||||
}
|
||||
}
|
||||
|
||||
func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEditor {
|
||||
indices := make([]int, 0)
|
||||
seen := map[int]struct{}{}
|
||||
@ -2203,19 +2617,25 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd
|
||||
out := make([]service.ConfigProfileInstanceEditor, 0, len(indices))
|
||||
for _, idx := range indices {
|
||||
prefix := fmt.Sprintf("instances[%d].", idx)
|
||||
inputBindings := parseInputBindingForm(form, prefix)
|
||||
serviceBindings := parseServiceBindingForm(form, prefix)
|
||||
outputBindings := parseOutputBindingForm(form, prefix)
|
||||
inst := service.ConfigProfileInstanceEditor{
|
||||
Name: strings.TrimSpace(form.Get(prefix + "name")),
|
||||
Template: strings.TrimSpace(form.Get(prefix + "template")),
|
||||
VideoSourceRef: firstString(inputBindingValue(inputBindings, "video_input_main"), strings.TrimSpace(form.Get(prefix+"video_source_ref"))),
|
||||
DisplayName: strings.TrimSpace(form.Get(prefix + "display_name")),
|
||||
RTSPURL: strings.TrimSpace(form.Get(prefix + "rtsp_url")),
|
||||
PublishHLSPath: strings.TrimSpace(form.Get(prefix + "publish_hls_path")),
|
||||
PublishRTSPPort: strings.TrimSpace(form.Get(prefix + "publish_rtsp_port")),
|
||||
PublishRTSPPath: strings.TrimSpace(form.Get(prefix + "publish_rtsp_path")),
|
||||
ChannelNo: strings.TrimSpace(form.Get(prefix + "channel_no")),
|
||||
PublishHLSPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_hls_path"), strings.TrimSpace(form.Get(prefix+"publish_hls_path"))),
|
||||
PublishRTSPPort: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_port"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_port"))),
|
||||
PublishRTSPPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_path"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_path"))),
|
||||
ChannelNo: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "channel_no"), strings.TrimSpace(form.Get(prefix+"channel_no"))),
|
||||
InputBindings: inputBindings,
|
||||
ServiceBindings: serviceBindings,
|
||||
OutputBindings: outputBindings,
|
||||
AdvancedParams: parseAdvancedParams(strings.TrimSpace(form.Get(prefix + "advanced_params"))),
|
||||
Delete: strings.TrimSpace(form.Get(prefix+"delete")) == "1",
|
||||
}
|
||||
if inst.Name != "" || inst.RTSPURL != "" || inst.Delete {
|
||||
if inst.Name != "" || inst.VideoSourceRef != "" || len(inst.ServiceBindings) > 0 || len(inst.OutputBindings) > 0 || inst.Delete {
|
||||
out = append(out, inst)
|
||||
}
|
||||
}
|
||||
@ -2224,10 +2644,7 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd
|
||||
if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" {
|
||||
templateName = strings.TrimSpace(out[0].Template)
|
||||
}
|
||||
out = append(out, service.ConfigProfileInstanceEditor{
|
||||
Template: templateName,
|
||||
PublishRTSPPort: "8555",
|
||||
})
|
||||
out = append(out, newProfileInstanceDraft(templateName, nextProfileInstanceName(out)))
|
||||
}
|
||||
if len(out) > 0 {
|
||||
fallbackTemplate := strings.TrimSpace(out[0].Template)
|
||||
@ -2243,6 +2660,134 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd
|
||||
return out
|
||||
}
|
||||
|
||||
func parseInputBindingForm(form url.Values, prefix string) map[string]service.InputBindingEditor {
|
||||
bindings := map[string]service.InputBindingEditor{}
|
||||
for key := range form {
|
||||
if !strings.HasPrefix(key, prefix+"input_bindings.") || !strings.HasSuffix(key, ".video_source_ref") {
|
||||
continue
|
||||
}
|
||||
slot := strings.TrimPrefix(key, prefix+"input_bindings.")
|
||||
slot = strings.TrimSuffix(slot, ".video_source_ref")
|
||||
slot = strings.TrimSpace(slot)
|
||||
if slot == "" {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(form.Get(key))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
bindings[slot] = service.InputBindingEditor{VideoSourceRef: value}
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func parseServiceBindingForm(form url.Values, prefix string) map[string]service.ServiceBindingEditor {
|
||||
bindings := map[string]service.ServiceBindingEditor{}
|
||||
for key := range form {
|
||||
if !strings.HasPrefix(key, prefix+"service_bindings.") || !strings.HasSuffix(key, ".service_ref") {
|
||||
continue
|
||||
}
|
||||
slot := strings.TrimPrefix(key, prefix+"service_bindings.")
|
||||
slot = strings.TrimSuffix(slot, ".service_ref")
|
||||
slot = strings.TrimSpace(slot)
|
||||
if slot == "" {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(form.Get(key))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
bindings[slot] = service.ServiceBindingEditor{ServiceRef: value}
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func parseOutputBindingForm(form url.Values, prefix string) map[string]service.OutputBindingEditor {
|
||||
bindings := map[string]service.OutputBindingEditor{}
|
||||
for key := range form {
|
||||
if !strings.HasPrefix(key, prefix+"output_bindings.") {
|
||||
continue
|
||||
}
|
||||
slotField := strings.TrimPrefix(key, prefix+"output_bindings.")
|
||||
dot := strings.LastIndex(slotField, ".")
|
||||
if dot <= 0 {
|
||||
continue
|
||||
}
|
||||
slot := strings.TrimSpace(slotField[:dot])
|
||||
field := strings.TrimSpace(slotField[dot+1:])
|
||||
if slot == "" || field == "" {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(form.Get(key))
|
||||
entry := bindings[slot]
|
||||
switch field {
|
||||
case "publish_hls_path":
|
||||
entry.PublishHLSPath = value
|
||||
case "publish_rtsp_port":
|
||||
entry.PublishRTSPPort = value
|
||||
case "publish_rtsp_path":
|
||||
entry.PublishRTSPPath = value
|
||||
case "channel_no":
|
||||
entry.ChannelNo = value
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(entry.PublishHLSPath) == "" && strings.TrimSpace(entry.PublishRTSPPort) == "" &&
|
||||
strings.TrimSpace(entry.PublishRTSPPath) == "" && strings.TrimSpace(entry.ChannelNo) == "" {
|
||||
continue
|
||||
}
|
||||
bindings[slot] = entry
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func inputBindingValue(bindings map[string]service.InputBindingEditor, slot string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(bindings[slot].VideoSourceRef)
|
||||
}
|
||||
|
||||
func outputBindingFormValue(bindings map[string]service.OutputBindingEditor, slot string, field string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
item, ok := bindings[slot]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch field {
|
||||
case "publish_hls_path":
|
||||
return strings.TrimSpace(item.PublishHLSPath)
|
||||
case "publish_rtsp_port":
|
||||
return strings.TrimSpace(item.PublishRTSPPort)
|
||||
case "publish_rtsp_path":
|
||||
return strings.TrimSpace(item.PublishRTSPPath)
|
||||
case "channel_no":
|
||||
return strings.TrimSpace(item.ChannelNo)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func selectedIDsFromQuery(values []string) []string {
|
||||
values = cleanFormList(values)
|
||||
if len(values) == 0 {
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
doc.template = graph;
|
||||
|
||||
const fallbackNodeTypes = [
|
||||
{ type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${rtsp_url}" } },
|
||||
{ type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${slot:video_input_main.url}" } },
|
||||
{ type: "input_file", label: "文件输入", category: "输入", icon: "file", description: "从本地视频文件读取帧,常用于离线验证和回放。", defaults: { id: "input_file", type: "input_file", role: "source", enable: true } },
|
||||
{ type: "preprocess", label: "图像预处理", category: "处理", icon: "adjust", description: "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", defaults: { id: "preprocess", type: "preprocess", role: "filter", enable: true, dst_format: "rgb" } },
|
||||
{ type: "ai_scrfd", label: "SCRFD 人脸检测", category: "AI 推理", icon: "scan-face", description: "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", defaults: { id: "ai_scrfd", type: "ai_scrfd", role: "filter", enable: true } },
|
||||
@ -56,7 +56,7 @@
|
||||
const coreEdgeKeys = new Set(["from", "to"]);
|
||||
const paramSchemas = {
|
||||
input_rtsp: [
|
||||
{ key: "url", label: "RTSP 地址", type: "text", placeholder: "${rtsp_url}" },
|
||||
{ key: "url", label: "RTSP 地址", type: "text", placeholder: "${slot:video_input_main.url}" },
|
||||
{ key: "fps", label: "输入帧率", type: "number", step: "1" },
|
||||
{ key: "width", label: "宽度", type: "number", step: "1" },
|
||||
{ key: "height", label: "高度", type: "number", step: "1" },
|
||||
|
||||
@ -116,6 +116,18 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
.nav-section{padding:14px 10px 6px;font-size:11px;color:var(--sidebar-muted);text-transform:uppercase;letter-spacing:.06em}
|
||||
.side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500}
|
||||
.side-nav a:hover{background:var(--sidebar-hover)}
|
||||
.side-nav .nav-group{margin:0}
|
||||
.side-nav .nav-group summary{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500;list-style:none;cursor:pointer;user-select:none}
|
||||
.side-nav .nav-group summary:hover{background:var(--sidebar-hover)}
|
||||
.side-nav .nav-group summary::-webkit-details-marker{display:none}
|
||||
.side-nav .nav-group summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--sidebar-muted);border-bottom:1.5px solid var(--sidebar-muted);transform:rotate(45deg);transition:transform .16s ease,border-color .16s ease}
|
||||
.side-nav .nav-group[open] summary::after{transform:rotate(225deg);border-color:var(--sidebar-text)}
|
||||
.side-nav .nav-group[open] summary{background:var(--sidebar-hover)}
|
||||
.side-nav .nav-group-items{display:flex;flex-direction:column;gap:4px;margin-top:4px}
|
||||
.side-nav .nav-subitem{padding:8px 10px 8px 34px;font-size:12px;font-weight:500;color:var(--sidebar-muted)}
|
||||
.side-nav .nav-subitem:hover{background:var(--sidebar-hover);color:var(--sidebar-text)}
|
||||
.side-nav .nav-subicon{width:22px;height:20px;font-size:9px}
|
||||
.side-nav .nav-subicon .ui-icon{width:12px;height:12px}
|
||||
.nav-icon{width:28px;height:24px;border-radius:3px;border:1px solid rgba(255,255,255,.12);display:grid;place-items:center;font-size:10px;color:var(--primary)}
|
||||
.nav-icon .ui-icon{width:14px;height:14px;stroke-width:1.75}
|
||||
|
||||
@ -232,8 +244,19 @@ tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary
|
||||
.selector-card .actions{margin-top:auto}
|
||||
.panel-block{border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);padding:14px}
|
||||
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
|
||||
.service-actions-row{margin-top:16px}
|
||||
.scene-config-form{margin-top:14px}
|
||||
.scene-summary-details{margin-top:12px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
|
||||
.scene-summary-details summary{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;font-weight:500;color:var(--muted);list-style:none}
|
||||
.scene-summary-details summary::before{content:"";width:7px;height:7px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .16s ease}
|
||||
.scene-summary-details summary::-webkit-details-marker{display:none}
|
||||
.scene-summary-details[open] summary::before{transform:rotate(45deg)}
|
||||
.scene-summary-details[open] summary{margin-bottom:10px;color:var(--text)}
|
||||
.scene-summary-details .info-list{margin-top:0}
|
||||
.scene-actions-row{margin-top:12px}
|
||||
.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px}
|
||||
.field-grid label span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
|
||||
.field-grid label>span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
|
||||
.field-grid label>span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px}
|
||||
.field-grid .full{grid-column:1/-1}
|
||||
.field-grid input,.field-grid select,.field-grid textarea{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--input-bg);color:var(--text);font:inherit}
|
||||
.field-grid textarea{resize:vertical;min-height:120px}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{{define "api"}}
|
||||
<div class="card">
|
||||
<div class="crumb">诊断 / 高级调试</div>
|
||||
<div class="crumb">系统管理 / 日志审计 / 高级调试</div>
|
||||
<h2>高级调试</h2>
|
||||
</div>
|
||||
|
||||
|
||||
@ -6,10 +6,10 @@
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>{{.AssetOverlay.Name}}</span></h2>
|
||||
</div>
|
||||
<a class="btn secondary" href="/ui/assets/overlays">返回叠加项列表</a>
|
||||
<a class="btn secondary" href="/ui/assets/overlays">返回调试参数列表</a>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>叠加项</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
|
||||
<div><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
|
||||
<div><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
|
||||
<div class="full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
|
||||
@ -3,14 +3,14 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>配置叠加项列表</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数列表</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>叠加项</th>
|
||||
<th>调试参数</th>
|
||||
<th>描述</th>
|
||||
<th>目标</th>
|
||||
</tr>
|
||||
@ -23,7 +23,7 @@
|
||||
<td>{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="3"><div class="empty-state compact"><div class="empty-title">还没有叠加项</div></div></td></tr>
|
||||
<tr><td colspan="3"><div class="empty-state compact"><div class="empty-title">还没有调试参数</div></div></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -34,7 +34,7 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>叠加项详情</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数详情</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button
|
||||
@ -46,7 +46,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>叠加项</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
|
||||
<div><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
|
||||
<div><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
|
||||
<div class="full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
|
||||
@ -1,99 +0,0 @@
|
||||
{{define "asset_profile"}}
|
||||
{{template "asset_tabs" .}}
|
||||
{{if .AssetProfileEditor}}
|
||||
<form method="post" action="/ui/assets/profiles/{{.AssetProfileEditor.Name}}">
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置</span></h2>
|
||||
</div>
|
||||
<a class="btn secondary" href="/ui/assets/profiles">返回业务配置列表</a>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label><span>业务配置名称</span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
|
||||
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
|
||||
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
|
||||
<label><span>队列大小</span><input class="mono" name="queue_size" value="{{.AssetProfileEditor.Queue.Size}}" /></label>
|
||||
<label><span>队列策略</span><input name="queue_strategy" value="{{.AssetProfileEditor.Queue.Strategy}}" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频通道</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<span class="pill">{{len .AssetProfileEditor.Instances}} 路</span>
|
||||
<button class="btn secondary" type="submit" name="add_instance" value="1">{{icon "apply"}}<span>新增通道</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>通道</th>
|
||||
<th>通道显示名</th>
|
||||
<th>RTSP 输入</th>
|
||||
<th>HLS 输出</th>
|
||||
<th>RTSP 输出</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $inst := .AssetProfileEditor.Instances}}
|
||||
<tr {{if $inst.Delete}}class="muted-row"{{end}}>
|
||||
<td class="mono">{{$inst.Name}}</td>
|
||||
<td>{{if $inst.Delete}}<span class="pill warn">待删除</span>{{else}}{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.RTSPURL}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.PublishHLSPath}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{if $inst.PublishRTSPPort}}{{$inst.PublishRTSPPort}}{{end}}{{if $inst.PublishRTSPPath}} {{$inst.PublishRTSPPath}}{{end}}{{end}}</td>
|
||||
<td>
|
||||
<div class="actions compact">
|
||||
<a class="btn ghost" href="#profile-instance-{{$i}}">编辑</a>
|
||||
{{if $inst.Delete}}
|
||||
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="0">撤销删除</button>
|
||||
{{else}}
|
||||
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="1">删除</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{range $i, $inst := .AssetProfileEditor.Instances}}
|
||||
<details id="profile-instance-{{$i}}" class="card collapsible profile-instance-editor" {{if or $inst.Delete (not $inst.Name)}}open{{end}}>
|
||||
<summary class="title-with-icon">{{icon "device"}}<span>{{$inst.Name}}</span></summary>
|
||||
<div class="field-grid profile-instance-grid">
|
||||
<input type="hidden" name="instances[{{$i}}].delete" value="{{if $inst.Delete}}1{{else}}0{{end}}" />
|
||||
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
|
||||
<label><span>通道名</span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" /></label>
|
||||
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" /></label>
|
||||
<label><span>通道号</span><input name="instances[{{$i}}].channel_no" value="{{$inst.ChannelNo}}" /></label>
|
||||
<label class="full"><span>RTSP 输入</span><input class="mono" name="instances[{{$i}}].rtsp_url" value="{{$inst.RTSPURL}}" /></label>
|
||||
<label class="full"><span>HLS 输出</span><input class="mono" name="instances[{{$i}}].publish_hls_path" value="{{$inst.PublishHLSPath}}" /></label>
|
||||
<label><span>RTSP 输出端口</span><input class="mono" name="instances[{{$i}}].publish_rtsp_port" value="{{$inst.PublishRTSPPort}}" /></label>
|
||||
<label><span>RTSP 输出路径</span><input class="mono" name="instances[{{$i}}].publish_rtsp_path" value="{{$inst.PublishRTSPPath}}" /></label>
|
||||
<label class="full"><span>高级设置 JSON</span><textarea name="instances[{{$i}}].advanced_params" rows="5" class="code-input">{{if $inst.AdvancedParams}}{{json $inst.AdvancedParams}}{{end}}</textarea></label>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
|
||||
<pre>{{json .AssetProfileEditor.Raw}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{template "asset_tabs_end" .}}
|
||||
{{end}}
|
||||
@ -33,12 +33,15 @@
|
||||
|
||||
{{if .AssetProfileEditor}}
|
||||
<form method="post" action="/ui/plans/{{.AssetProfileEditor.Name}}">
|
||||
<input type="hidden" id="active-instance-input" name="active_instance" value="{{.ActiveInstanceIndex}}" />
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button type="submit">{{icon "apply"}}<span>保存场景配置</span></button>
|
||||
<a class="btn secondary" href="/ui/plans?name={{.AssetProfileEditor.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
@ -48,7 +51,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label><span>场景名称</span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
|
||||
<label><span>场景名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
|
||||
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
|
||||
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
|
||||
@ -57,43 +60,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
<span>对象存储</span>
|
||||
<select name="object_storage_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range .AssetIntegrations}}{{if eq .Type "object_storage"}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.ObjectStorageRef .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>认证服务</span>
|
||||
<select name="token_service_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range .AssetIntegrations}}{{if eq .Type "token_service"}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.TokenServiceRef .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>告警服务</span>
|
||||
<select name="alarm_service_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range .AssetIntegrations}}{{if eq .Type "alarm_service"}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.AlarmServiceRef .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
@ -108,29 +74,65 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>通道</th>
|
||||
<th>通道显示名</th>
|
||||
<th>RTSP 输入</th>
|
||||
<th>HLS 输出</th>
|
||||
<th>RTSP 输出</th>
|
||||
<th>模板</th>
|
||||
<th>输入绑定</th>
|
||||
<th>服务绑定</th>
|
||||
<th>输出绑定</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $inst := .AssetProfileEditor.Instances}}
|
||||
<tr {{if $inst.Delete}}class="muted-row"{{end}}>
|
||||
{{$template := index $.AssetTemplateMap $inst.Template}}
|
||||
<tr data-instance-row="{{$i}}" {{if eq $.ActiveInstanceIndex $i}}class="selected"{{else if $inst.Delete}}class="muted-row"{{end}}>
|
||||
<td class="mono">{{$inst.Name}}</td>
|
||||
<td>{{if $inst.Delete}}<span class="pill warn">待删除</span>{{else}}{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.RTSPURL}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.PublishHLSPath}}{{end}}</td>
|
||||
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{if $inst.PublishRTSPPort}}{{$inst.PublishRTSPPort}}{{end}}{{if $inst.PublishRTSPPath}} {{$inst.PublishRTSPPath}}{{end}}{{end}}</td>
|
||||
<td>{{if $inst.Delete}}<span class="pill warn">待删除</span>{{else}}<span class="mono">{{$inst.Template}}</span>{{end}}</td>
|
||||
<td>
|
||||
{{if $inst.Delete}}-{{else}}
|
||||
{{if $template.Slots.Inputs}}
|
||||
{{range $slot := $template.Slots.Inputs}}
|
||||
<div class="stacked-meta">
|
||||
<span>{{$slot.Description}}</span>
|
||||
<span class="mono">{{if inputBindingRef $inst.InputBindings $slot.Name}}{{inputBindingRef $inst.InputBindings $slot.Name}}{{else}}-{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}-{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if $inst.Delete}}-{{else}}
|
||||
{{if $template.Slots.Services}}
|
||||
{{range $slot := $template.Slots.Services}}
|
||||
<div class="stacked-meta">
|
||||
<span>{{$slot.Description}}</span>
|
||||
<span class="mono">{{if serviceBindingRef $inst.ServiceBindings $slot.Name}}{{serviceBindingRef $inst.ServiceBindings $slot.Name}}{{else}}-{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}-{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if $inst.Delete}}-{{else}}
|
||||
{{if $template.Slots.Outputs}}
|
||||
{{range $slot := $template.Slots.Outputs}}
|
||||
<div class="stacked-meta">
|
||||
<span>{{$slot.Description}}</span>
|
||||
<span class="mono">
|
||||
{{if outputBindingValue $inst.OutputBindings $slot.Name "publish_hls_path"}}
|
||||
{{outputBindingValue $inst.OutputBindings $slot.Name "publish_hls_path"}}
|
||||
{{else if outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_path"}}
|
||||
{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}} {{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_path"}}
|
||||
{{else}}-{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}-{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions compact">
|
||||
<a class="btn ghost" href="#profile-instance-{{$i}}">编辑</a>
|
||||
{{if $inst.Delete}}
|
||||
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="0">撤销删除</button>
|
||||
{{else}}
|
||||
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="1">删除</button>
|
||||
{{end}}
|
||||
<button type="button" class="btn ghost js-open-instance-editor" data-instance-index="{{$i}}" data-target="profile-instance-{{$i}}">编辑</button>
|
||||
<button class="btn secondary" type="submit" name="remove_instance" value="{{$i}}">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -139,29 +141,59 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{range $i, $inst := .AssetProfileEditor.Instances}}
|
||||
<details id="profile-instance-{{$i}}" class="card collapsible profile-instance-editor" {{if or $inst.Delete (not $inst.Name)}}open{{end}}>
|
||||
<summary class="title-with-icon">{{icon "device"}}<span>{{$inst.Name}}</span></summary>
|
||||
<div class="field-grid profile-instance-grid">
|
||||
<input type="hidden" name="instances[{{$i}}].delete" value="{{if $inst.Delete}}1{{else}}0{{end}}" />
|
||||
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
|
||||
<label><span>通道名</span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" /></label>
|
||||
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" /></label>
|
||||
<label><span>通道号</span><input name="instances[{{$i}}].channel_no" value="{{$inst.ChannelNo}}" /></label>
|
||||
<label class="full"><span>RTSP 输入</span><input class="mono" name="instances[{{$i}}].rtsp_url" value="{{$inst.RTSPURL}}" /></label>
|
||||
<label class="full"><span>HLS 输出</span><input class="mono" name="instances[{{$i}}].publish_hls_path" value="{{$inst.PublishHLSPath}}" /></label>
|
||||
<label><span>RTSP 输出端口</span><input class="mono" name="instances[{{$i}}].publish_rtsp_port" value="{{$inst.PublishRTSPPort}}" /></label>
|
||||
<label><span>RTSP 输出路径</span><input class="mono" name="instances[{{$i}}].publish_rtsp_path" value="{{$inst.PublishRTSPPath}}" /></label>
|
||||
<label class="full"><span>高级设置 JSON</span><textarea name="instances[{{$i}}].advanced_params" rows="5" class="code-input">{{if $inst.AdvancedParams}}{{json $inst.AdvancedParams}}{{end}}</textarea></label>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{if .AssetProfileEditor.Instances}}
|
||||
<div class="card">
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>通道详情</span></h2>
|
||||
<div class="form-hint">当前通道的修改会在点击“保存场景配置”后统一生效。</div>
|
||||
</div>
|
||||
</div>
|
||||
{{range $i, $inst := .AssetProfileEditor.Instances}}
|
||||
{{$template := index $.AssetTemplateMap $inst.Template}}
|
||||
<section id="profile-instance-{{$i}}" class="profile-instance-editor" data-instance-editor="{{$i}}" {{if ne $.ActiveInstanceIndex $i}}hidden{{end}}>
|
||||
<div class="field-grid profile-instance-grid">
|
||||
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
|
||||
<label><span>通道名<span class="required-mark">*</span></span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" /></label>
|
||||
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" /></label>
|
||||
<label><span>模板</span><input class="mono" value="{{$inst.Template}}" readonly /></label>
|
||||
{{range $slot := $template.Slots.Inputs}}
|
||||
<label>
|
||||
<span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span>
|
||||
<select name="instances[{{$i}}].input_bindings.{{$slot.Name}}.video_source_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range $.AssetVideoSources}}
|
||||
<option value="{{.Name}}" {{if eq (inputBindingRef $inst.InputBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Area}} - {{.Area}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
{{end}}
|
||||
{{range $slot := $template.Slots.Services}}
|
||||
<label>
|
||||
<span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span>
|
||||
<select name="instances[{{$i}}].service_bindings.{{$slot.Name}}.service_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range $.AssetIntegrations}}{{if eq .Type $slot.Type}}
|
||||
<option value="{{.Name}}" {{if eq (serviceBindingRef $inst.ServiceBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}{{end}}
|
||||
</select>
|
||||
</label>
|
||||
{{end}}
|
||||
{{range $slot := $template.Slots.Outputs}}
|
||||
<label><span>{{$slot.Description}} HLS 路径</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_hls_path" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_hls_path"}}" /></label>
|
||||
<label><span>{{$slot.Description}} RTSP 路径</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_path" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_path"}}" /></label>
|
||||
<label><span>{{$slot.Description}} RTSP 端口</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_port" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}" /></label>
|
||||
<label><span>{{$slot.Description}} 通道号</span><input name="instances[{{$i}}].output_bindings.{{$slot.Name}}.channel_no" value="{{outputBindingValue $inst.OutputBindings $slot.Name "channel_no"}}" /></label>
|
||||
{{end}}
|
||||
<details style="grid-column:1/-1;margin-top:0.25rem">
|
||||
<summary style="cursor:pointer;font-size:0.875rem;font-weight:500;color:var(--text-secondary)">高级设置 JSON</summary>
|
||||
<textarea name="instances[{{$i}}].advanced_params" rows="5" class="code-input" style="margin-top:0.5rem;width:100%;box-sizing:border-box">{{if $inst.AdvancedParams}}{{json $inst.AdvancedParams}}{{end}}</textarea>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
|
||||
<details class="card collapsible">
|
||||
|
||||
@ -13,11 +13,9 @@
|
||||
<div><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
|
||||
<div><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
|
||||
<div><span>MinIO</span><strong class="mono">{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>Bucket</span><strong>{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>取 token 接口</span><strong class="mono">{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>告警上报接口</span><strong class="mono">{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>租户编码</span><strong>{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>输入槽位</span><strong>{{len .AssetTemplate.Slots.Inputs}}</strong></div>
|
||||
<div><span>服务槽位</span><strong>{{len .AssetTemplate.Slots.Services}}</strong></div>
|
||||
<div><span>输出槽位</span><strong>{{len .AssetTemplate.Slots.Outputs}}</strong></div>
|
||||
<div class="full"><span>描述</span><strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>模板列表</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>识别模板列表</span></h2>
|
||||
<div class="form-hint">标准模板应作为基线,复制后再做定制化修改。空白模板仅用于高级场景。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
@ -105,11 +105,9 @@
|
||||
<div><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
|
||||
<div><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
|
||||
<div><span>MinIO</span><strong class="mono">{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>Bucket</span><strong>{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>取 token 接口</span><strong class="mono">{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}</strong></div>
|
||||
<div class="full"><span>告警上报接口</span><strong class="mono">{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>租户编码</span><strong>{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>输入槽位</span><strong>{{if .AssetTemplate.Slots.Inputs}}{{len .AssetTemplate.Slots.Inputs}}{{else}}0{{end}}</strong></div>
|
||||
<div><span>服务槽位</span><strong>{{if .AssetTemplate.Slots.Services}}{{len .AssetTemplate.Slots.Services}}{{else}}0{{end}}</strong></div>
|
||||
<div><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div>
|
||||
<div class="full">
|
||||
<span>描述</span>
|
||||
{{if .AssetTemplate.ReadOnly}}
|
||||
@ -131,6 +129,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>模板槽位</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
{{range .AssetTemplate.Slots.Inputs}}
|
||||
<div class="full"><span>输入</span><strong class="mono">{{.Name}}</strong> <span class="muted">{{.Type}}</span></div>
|
||||
{{end}}
|
||||
{{range .AssetTemplate.Slots.Services}}
|
||||
<div class="full"><span>服务</span><strong class="mono">{{.Name}}</strong> <span class="muted">{{.Type}}</span></div>
|
||||
{{end}}
|
||||
{{range .AssetTemplate.Slots.Outputs}}
|
||||
<div class="full"><span>输出</span><strong class="mono">{{.Name}}</strong> <span class="muted">{{.Type}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AssetTemplate.ReadOnly}}
|
||||
<div class="card">
|
||||
<div class="form-hint">标准模板保留在模板目录中,作为只读基线使用。需要调整流程时,请先复制为用户模板,再进入可视化编辑保存。</div>
|
||||
|
||||
@ -37,6 +37,15 @@
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/assets/integrations?new=1">{{icon "apply"}}<span>新增服务</span></a>
|
||||
{{if .AssetIntegration.Name}}
|
||||
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/ui/assets/integrations/{{.AssetIntegration.Name}}/delete">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
@ -53,7 +62,7 @@
|
||||
<tbody>
|
||||
{{range .AssetIntegrations}}
|
||||
<tr>
|
||||
<td class="mono">{{.Name}}</td>
|
||||
<td><a class="mono" href="/ui/assets/integrations?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{.TypeLabel}}</td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .AddressSummary}}{{.AddressSummary}}{{else}}-{{end}}</td>
|
||||
@ -67,39 +76,156 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetIntegration}}
|
||||
<form method="post" action="/ui/assets/integrations">
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label><span>服务名称</span><input name="name" value="{{.AssetIntegration.Name}}" {{if .AssetIntegrationEditing}}autofocus{{else}}readonly{{end}} /></label>
|
||||
<label>
|
||||
<span>服务类型</span>
|
||||
<select name="type" {{if not .AssetIntegrationEditing}}disabled{{end}}>
|
||||
<option value="object_storage" {{if eq .AssetIntegration.Type "object_storage"}}selected{{end}}>对象存储</option>
|
||||
<option value="token_service" {{if eq .AssetIntegration.Type "token_service"}}selected{{end}}>认证服务</option>
|
||||
<option value="alarm_service" {{if eq .AssetIntegration.Type "alarm_service"}}selected{{end}}>告警服务</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetIntegration.Description}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>启用</span><select name="enabled" {{if not .AssetIntegrationEditing}}disabled{{end}}><option value="1" {{if .AssetIntegration.Enabled}}selected{{end}}>启用</option><option value="0" {{if not .AssetIntegration.Enabled}}selected{{end}}>停用</option></select></label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<label><span>对象存储地址</span><input class="mono" name="endpoint" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>Bucket</span><input class="mono" name="bucket" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Bucket}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>Access Key</span><input class="mono" name="access_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>Secret Key</span><input class="mono" name="secret_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<label><span>Token 获取地址</span><input class="mono" name="get_token_url" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.GetTokenURL}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>认证用户名</span><input name="username" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Username}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>认证密码</span><input name="password" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Password}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>认证租户编码</span><input class="mono" name="tenant_code" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.TenantCode}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<label><span>消息上报地址</span><input class="mono" name="put_message_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>告警用户名</span><input name="alarm_username" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Username}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>告警密码</span><input name="alarm_password" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Password}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>告警租户编码</span><input class="mono" name="alarm_tenant_code" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.TenantCode}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
</div>
|
||||
|
||||
{{if .AssetIntegrationEditing}}
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else if eq .AssetTab "video-sources"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频源</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频源列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/assets/video-sources?new=1">{{icon "apply"}}<span>新增视频源</span></a>
|
||||
{{if .AssetVideoSource.Name}}
|
||||
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/ui/assets/video-sources/{{.AssetVideoSource.Name}}/delete">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetProfiles}}
|
||||
{{range .Instances}}
|
||||
<div class="asset-row">
|
||||
<span class="mono">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
|
||||
<span class="muted small">{{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}}</span>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>视频源名称</th>
|
||||
<th>类型</th>
|
||||
<th>区域</th>
|
||||
<th>输入地址</th>
|
||||
<th>分辨率</th>
|
||||
<th>帧率</th>
|
||||
<th>引用数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetVideoSources}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/assets/video-sources?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{.SourceTypeLabel}}</td>
|
||||
<td>{{if .Area}}{{.Area}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .Config.URL}}{{.Config.URL}}{{else}}-{{end}}</td>
|
||||
<td>{{if .Config.Resolution}}{{.Config.Resolution}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .Config.FPS}}{{.Config.FPS}}{{else}}-{{end}}</td>
|
||||
<td>{{.RefCount}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7"><div class="empty-state compact"><div class="empty-title">还没有视频源</div></div></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetVideoSource}}
|
||||
<form method="post" action="/ui/assets/video-sources">
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}</span></h2>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">还没有视频源</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label><span>视频源名称</span><input name="name" value="{{.AssetVideoSource.Name}}" {{if .AssetVideoSourceEditing}}autofocus{{else}}readonly{{end}} /></label>
|
||||
<label>
|
||||
<span>类型</span>
|
||||
<select name="source_type" {{if not .AssetVideoSourceEditing}}disabled{{end}}>
|
||||
<option value="rtsp" {{if eq .AssetVideoSource.SourceType "rtsp"}}selected{{end}}>RTSP</option>
|
||||
<option value="rtmp" {{if eq .AssetVideoSource.SourceType "rtmp"}}selected{{end}}>RTMP</option>
|
||||
<option value="file" {{if eq .AssetVideoSource.SourceType "file"}}selected{{end}}>文件</option>
|
||||
<option value="usb_camera" {{if eq .AssetVideoSource.SourceType "usb_camera"}}selected{{end}}>USB 摄像头</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>区域</span><input name="area" value="{{.AssetVideoSource.Area}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetVideoSource.Description}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>输入地址</span><input class="mono" name="url" value="{{.AssetVideoSource.Config.URL}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>标准分辨率</span><input name="resolution" value="{{.AssetVideoSource.Config.Resolution}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>像素尺寸</span><input class="mono" name="frame_size" value="{{.AssetVideoSource.Config.FrameSize}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>帧率</span><input class="mono" name="fps" value="{{.AssetVideoSource.Config.FPS}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>视频格式</span><input name="video_format" value="{{.AssetVideoSource.Config.VideoFormat}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>焦距</span><input name="focal_length" value="{{.AssetVideoSource.Config.FocalLength}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>安装高度</span><input name="mount_height" value="{{.AssetVideoSource.Config.MountHeight}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>安装角度</span><input name="mount_angle" value="{{.AssetVideoSource.Config.MountAngle}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
</div>
|
||||
{{if .AssetVideoSourceEditing}}
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="stats">
|
||||
<div class="stat accent-teal">
|
||||
<div class="k metric-label">{{icon "template"}}<span>识别模板</span></div>
|
||||
<div class="v">{{len .AssetTemplates}}</div>
|
||||
<div class="hint">{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}未定位到配置仓库{{end}}</div>
|
||||
<div class="hint">{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}标准模板与配置均存储在本地数据库{{end}}</div>
|
||||
</div>
|
||||
<div class="stat accent-green">
|
||||
<div class="k metric-label">{{icon "device"}}<span>视频源</span></div>
|
||||
<div class="v">{{.AssetInstanceCount}}</div>
|
||||
<div class="hint">当前从运行方案中解析</div>
|
||||
<div class="v">{{len .AssetVideoSources}}</div>
|
||||
<div class="hint">可复用输入流配置</div>
|
||||
</div>
|
||||
<div class="stat accent-slate">
|
||||
<div class="k metric-label">{{icon "overlay"}}<span>调试参数</span></div>
|
||||
@ -141,13 +267,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetProfiles}}
|
||||
{{range .Instances}}
|
||||
<div class="asset-row">
|
||||
<span class="mono">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
|
||||
<span class="muted small">{{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range .AssetVideoSources}}
|
||||
<a class="asset-row asset-link" href="/ui/assets/video-sources?name={{.Name}}">
|
||||
<span class="mono">{{.Name}}</span>
|
||||
<span class="muted small">{{if .Area}}{{.Area}} / {{end}}{{if .Config.Resolution}}{{.Config.Resolution}}{{else}}未设置分辨率{{end}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">还没有视频源</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<div class="crumb">诊断 / 审计记录</div>
|
||||
<div class="crumb">系统管理 / 日志审计 / 审计记录</div>
|
||||
<h2 class="title-with-icon">{{icon "audit"}}<span>审计记录</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<label><span>config_id</span><input name="config_id" value="{{.SelectedConfigID}}" /></label>
|
||||
<label><span>config_version</span><input name="config_version" value="{{.SelectedVersion}}" placeholder="留空自动生成" /></label>
|
||||
<div class="full">
|
||||
<span class="muted small">配置叠加项</span>
|
||||
<span class="muted small">调试参数</span>
|
||||
<div class="actions" style="margin-top:6px">
|
||||
{{range .ConfigSources.Overlays}}
|
||||
<label class="btn ghost">
|
||||
@ -65,7 +65,7 @@
|
||||
<div><span>业务名称</span><strong>{{if index .ConfigPreview.Metadata "business_name"}}{{index .ConfigPreview.Metadata "business_name"}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>模板</span><strong>{{index .ConfigPreview.Metadata "template"}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{index .ConfigPreview.Metadata "profile"}}</strong></div>
|
||||
<div><span>配置叠加项</span><strong class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>调试参数</span><strong class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div>
|
||||
<div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div>
|
||||
</div>
|
||||
@ -103,7 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{if and .ConfigStatus.PreviousConfig .ConfigStatus.Sha256 .ConfigStatus.PreviousConfig.Sha256 (eq .ConfigStatus.Metadata.ConfigID .ConfigStatus.PreviousConfig.Metadata.ConfigID) (eq .ConfigStatus.Metadata.ConfigVersion .ConfigStatus.PreviousConfig.Metadata.ConfigVersion) (ne .ConfigStatus.Sha256 .ConfigStatus.PreviousConfig.Sha256)}}
|
||||
<div class="muted small" style="margin-top:10px">当前运行与上一份配置回滚点的 <span class="mono">config_id/config_version</span> 相同,但文件内容不同,请以配置叠加项和 <span class="mono">sha</span> 为准。</div>
|
||||
<div class="muted small" style="margin-top:10px">当前运行与上一份配置回滚点的 <span class="mono">config_id/config_version</span> 相同,但文件内容不同,请以调试参数和 <span class="mono">sha</span> 为准。</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开配置预览</span></a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览场景配置</span></a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -33,7 +33,7 @@
|
||||
<div class="actions compact">
|
||||
<a class="btn ghost" href="#device-overview">概览</a>
|
||||
<a class="btn ghost" href="#device-runtime">运行与服务</a>
|
||||
<a class="btn ghost" href="#device-config">设备配置</a>
|
||||
<a class="btn ghost" href="#device-config">场景配置</a>
|
||||
<a class="btn ghost" href="#device-models">模型与资源</a>
|
||||
<a class="btn ghost" href="#device-observability">日志与指标</a>
|
||||
</div>
|
||||
@ -72,7 +72,7 @@
|
||||
<div><span>配置版本</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>模板</span><strong>{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>叠加项</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>调试参数</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>配置文件</span><strong class="mono">{{.ConfigStatus.ConfigPath}}</strong></div>
|
||||
<div><span>配置 SHA</span><strong class="mono">{{shortHash .ConfigStatus.Sha256}}</strong></div>
|
||||
</div>
|
||||
@ -83,7 +83,7 @@
|
||||
<div><span>配置版本</span><strong class="mono">{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>模板</span><strong>{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>叠加项</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>调试参数</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>最近下发任务</span><strong class="mono">{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{else}}
|
||||
@ -106,9 +106,8 @@
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>媒体服务</span><strong>{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}未确认{{end}}</strong></div>
|
||||
<div><span>上一份配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="actions stack">
|
||||
<div class="actions service-actions-row">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_start" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
@ -130,23 +129,38 @@
|
||||
<section class="panel-block" id="device-config">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "apply"}}<span>设备配置</span></h3>
|
||||
<h3 class="title-with-icon">{{icon "apply"}}<span>场景配置</span></h3>
|
||||
</div>
|
||||
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>编辑和上传候选配置</span></a>
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>当前配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else if .PersistedConfig}}{{.PersistedConfig.ConfigID}}{{else}}待读取{{end}}</strong></div>
|
||||
<div><span>候选配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="primary">应用候选配置</button>
|
||||
</form>
|
||||
<form id="device-scene-apply-form" method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply" class="scene-config-form">
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>选择场景配置</span>
|
||||
<select name="profile">
|
||||
{{range .AssetProfiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}{{if .BusinessName}} - {{.BusinessName}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{{if .AssetProfile}}
|
||||
<details class="scene-summary-details">
|
||||
<summary>查看所选场景配置详情</summary>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>业务名称</span><strong>{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>关联模板</span><strong>{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>视频通道</span><strong>{{len .AssetProfile.Instances}} 路</strong></div>
|
||||
<div><span>说明</span><strong>{{if .AssetProfile.Description}}{{.AssetProfile.Description}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</form>
|
||||
<div class="actions scene-actions-row">
|
||||
<button type="submit" form="device-scene-apply-form" class="primary">下发场景配置</button>
|
||||
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览场景配置</span></a>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="rollback" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="secondary">回滚到上一份</button>
|
||||
<button type="submit" class="secondary">回滚上一版</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@ -160,13 +174,13 @@
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>模型入口</span><strong>通过模型管理页上传到设备</strong></div>
|
||||
<div><span>人脸库</span><strong>通过识别配置与资源页维护</strong></div>
|
||||
<div><span>人脸库</span><strong>通过场景配置与基础配置维护</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload">
|
||||
<button type="submit" class="secondary">重载人脸库</button>
|
||||
</form>
|
||||
<a class="btn ghost" href="/ui/assets">查看识别配置资产</a>
|
||||
<a class="btn ghost" href="/ui/assets">查看基础配置</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@ -27,15 +27,15 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>下发业务配置</span></h2>
|
||||
<div class="muted">先选择一份已有业务配置,再为已选设备创建下发任务。</div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>下发场景配置</span></h2>
|
||||
<div class="muted">先选择一份已有场景配置,再为已选设备创建下发任务。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/batch-config">
|
||||
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>业务配置</span>
|
||||
<label class="full"><span>场景配置</span>
|
||||
<select name="profile">
|
||||
{{range .AssetProfiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}{{if .BusinessName}} - {{.BusinessName}}{{end}}</option>
|
||||
@ -53,12 +53,12 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>业务配置摘要</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>场景配置摘要</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetProfile}}
|
||||
<div class="info-list">
|
||||
<div><span>业务配置</span><strong>{{.AssetProfile.Name}}</strong></div>
|
||||
<div><span>场景配置</span><strong>{{.AssetProfile.Name}}</strong></div>
|
||||
<div><span>业务名称</span><strong>{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>关联模板</span><strong>{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>视频通道</span><strong>{{len .AssetProfile.Instances}} 路</strong></div>
|
||||
@ -77,7 +77,7 @@
|
||||
<th>通道</th>
|
||||
<th>显示名称</th>
|
||||
<th>站点</th>
|
||||
<th>RTSP</th>
|
||||
<th>视频源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -86,7 +86,7 @@
|
||||
<td class="mono">{{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}}</td>
|
||||
<td>{{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}}</td>
|
||||
<td>{{if .SiteName}}{{.SiteName}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .RTSPURL}}{{.RTSPURL}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .VideoSourceRef}}{{.VideoSourceRef}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
@ -95,8 +95,8 @@
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">还没有可用业务配置</div>
|
||||
<div class="muted">请先到识别配置中创建业务配置,再回来下发。</div>
|
||||
<div class="empty-title">还没有可用场景配置</div>
|
||||
<div class="muted">请先到场景配置中创建配置,再回来下发。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@ -42,9 +42,9 @@
|
||||
<button type="submit" name="action" value="media_restart" class="primary">重启服务</button>
|
||||
<button type="submit" name="action" value="media_start" class="secondary">启动服务</button>
|
||||
<button type="submit" name="action" value="media_stop" class="danger">停止服务</button>
|
||||
<button type="submit" name="action" value="reload" class="secondary" {{if .ReloadSummary}}onclick='return confirm("将重载当前业务配置:{{.ReloadSummary}}")'{{end}}>重载配置</button>
|
||||
<button type="submit" name="action" value="rollback" class="secondary" {{if .RollbackSummary}}onclick='return confirm("将回滚到上一版业务配置:{{.RollbackSummary}}")'{{end}}>回滚配置</button>
|
||||
<a class="btn secondary" href="{{.BatchConfigURL}}">下发业务配置</a>
|
||||
<button type="submit" name="action" value="reload" class="secondary" {{if .ReloadSummary}}onclick='return confirm("将重载当前场景配置:{{.ReloadSummary}}")'{{end}}>重载配置</button>
|
||||
<button type="submit" name="action" value="rollback" class="secondary" {{if .RollbackSummary}}onclick='return confirm("将回滚到上一版场景配置:{{.RollbackSummary}}")'{{end}}>回滚配置</button>
|
||||
<a class="btn secondary" href="{{.BatchConfigURL}}">下发场景配置</a>
|
||||
<a class="btn secondary" href="/ui/devices">清空选择</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>诊断工作台</h2>
|
||||
<h2>日志审计</h2>
|
||||
<div class="muted small">按设备查看日志与运行指标,并进入审计记录和高级调试。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/api">高级调试</a>
|
||||
</div>
|
||||
@ -39,18 +40,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="card">
|
||||
<h2>系统状态</h2>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<a class="btn ghost" href="/ui/system">进入系统状态</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>审计记录</h2>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<a class="btn ghost" href="/ui/audit">进入审计记录</a>
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>审计记录</h2>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/audit">进入审计记录</a>
|
||||
</div>
|
||||
<div class="muted small">查看任务执行、设备操作和配置下发相关的审计记录。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="/ui/assets/vendor/tabler.min.css" />
|
||||
<link rel="stylesheet" href="/ui/assets/style.css?v=20260429-theme16" />
|
||||
<link rel="stylesheet" href="/ui/assets/graph_editor.css?v=20260429-theme16" />
|
||||
<link rel="stylesheet" href="/ui/assets/style.css?v=20260429-ia02" />
|
||||
<link rel="stylesheet" href="/ui/assets/graph_editor.css?v=20260429-ia01" />
|
||||
</head>
|
||||
<body data-theme="blue-dark">
|
||||
<div class="app-shell">
|
||||
@ -22,9 +22,21 @@
|
||||
<div class="nav-section">主模块</div>
|
||||
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
|
||||
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
|
||||
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>识别配置</span></a>
|
||||
<a href="/ui/plans"><span class="nav-icon">{{icon "profile"}}</span><span>场景配置</span></a>
|
||||
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>基础配置</span></a>
|
||||
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务</span></a>
|
||||
<a href="/ui/diagnostics"><span class="nav-icon">{{icon "logs"}}</span><span>诊断</span></a>
|
||||
<details class="nav-group" id="system-nav-group">
|
||||
<summary>
|
||||
<span class="nav-icon">{{icon "system"}}</span>
|
||||
<span>系统管理</span>
|
||||
</summary>
|
||||
<div class="nav-group-items">
|
||||
<a class="nav-subitem" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
|
||||
<a class="nav-subitem" href="/ui/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
@ -43,7 +55,7 @@
|
||||
<button type="button" data-theme-option="graphite-gold">石墨金色</button>
|
||||
</div>
|
||||
</div>
|
||||
<a class="topbar-icon-btn" href="/ui/diagnostics" aria-label="告警" title="告警">
|
||||
<a class="topbar-icon-btn" href="/ui/diagnostics" aria-label="日志审计" title="日志审计">
|
||||
{{icon "bell"}}
|
||||
<span class="topbar-dot" aria-hidden="true"></span>
|
||||
</a>
|
||||
@ -223,6 +235,51 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceRow = event.target.closest("[data-instance-row]");
|
||||
if (instanceRow && !event.target.closest("button, a, input, select, textarea")) {
|
||||
const index = instanceRow.getAttribute("data-instance-row");
|
||||
const activeInput = document.getElementById("active-instance-input");
|
||||
if (activeInput && index !== null) {
|
||||
activeInput.value = index;
|
||||
}
|
||||
document.querySelectorAll("[data-instance-editor]").forEach(function (panel) {
|
||||
panel.hidden = panel.getAttribute("data-instance-editor") !== index;
|
||||
});
|
||||
document.querySelectorAll("[data-instance-row]").forEach(function (row) {
|
||||
row.classList.toggle("selected", row.getAttribute("data-instance-row") === index);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceEditorToggle = event.target.closest(".js-open-instance-editor");
|
||||
if (instanceEditorToggle) {
|
||||
event.preventDefault();
|
||||
const index = instanceEditorToggle.getAttribute("data-instance-index");
|
||||
const activeInput = document.getElementById("active-instance-input");
|
||||
if (activeInput && index !== null) {
|
||||
activeInput.value = index;
|
||||
}
|
||||
document.querySelectorAll("[data-instance-editor]").forEach(function (panel) {
|
||||
panel.hidden = panel.getAttribute("data-instance-editor") !== index;
|
||||
});
|
||||
document.querySelectorAll("[data-instance-row]").forEach(function (row) {
|
||||
row.classList.toggle("selected", row.getAttribute("data-instance-row") === index);
|
||||
});
|
||||
const targetId = instanceEditorToggle.getAttribute("data-target");
|
||||
const panel = targetId ? document.getElementById(targetId) : null;
|
||||
if (panel) {
|
||||
panel.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
const input = panel.querySelector("input:not([type=hidden]):not([readonly]), textarea, select");
|
||||
if (input) {
|
||||
input.focus();
|
||||
if (input.tagName === "INPUT" || input.tagName === "TEXTAREA") {
|
||||
input.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const editCancel = event.target.closest(".js-inline-edit-cancel");
|
||||
if (editCancel) {
|
||||
event.preventDefault();
|
||||
@ -236,6 +293,22 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const systemNavGroup = document.getElementById("system-nav-group");
|
||||
if (systemNavGroup) {
|
||||
const path = window.location.pathname || "";
|
||||
if (
|
||||
path === "/ui/models" ||
|
||||
path === "/ui/resources" ||
|
||||
path === "/ui/diagnostics" ||
|
||||
path === "/ui/system" ||
|
||||
path === "/ui/audit" ||
|
||||
path === "/ui/api"
|
||||
) {
|
||||
systemNavGroup.open = true;
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -2,25 +2,24 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>模型管理工作流</h2>
|
||||
<div class="muted small">面向检测、识别和人脸库模型的设备级部署入口。</div>
|
||||
<h2>统一模型目录</h2>
|
||||
<div class="muted small">平台统一维护识别模型版本,设备页只查看已生效版本与同步状态。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
|
||||
</div>
|
||||
<div class="model-summary">
|
||||
<div class="summary-item"><div class="summary-label">目标节点</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">可选择单台设备上传模型</div></div>
|
||||
<div class="summary-item"><div class="summary-label">部署方式</div><div class="summary-value">单节点</div><div class="summary-hint">在本页直接上传到目标设备</div></div>
|
||||
<div class="summary-item"><div class="summary-label">模型类型</div><div class="summary-value">检测/识别</div><div class="summary-hint">二进制模型文件</div></div>
|
||||
<div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">DB</div><div class="summary-hint">通过识别配置页维护</div></div>
|
||||
<div class="summary-item"><div class="summary-label">模型目录</div><div class="summary-value">统一管理</div><div class="summary-hint">检测 / 识别模型统一发布</div></div>
|
||||
<div class="summary-item"><div class="summary-label">发布版本</div><div class="summary-value">当前版本</div><div class="summary-hint">按场景配置引用生效</div></div>
|
||||
<div class="summary-item"><div class="summary-label">设备版本状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备版本覆盖</div></div>
|
||||
<div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">统一管理</div><div class="summary-hint">在人脸库资源中维护</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>设备模型管理</h2>
|
||||
<h2>设备版本状态</h2>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>节点</th><th>状态</th><th>管理地址</th><th>模型操作</th><th>人脸库</th></tr>
|
||||
<tr><th>节点</th><th>状态</th><th>管理地址</th><th>模型版本</th><th>人脸库</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
@ -31,20 +30,11 @@
|
||||
</td>
|
||||
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
|
||||
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/api/devices/{{.DeviceID}}/models">查看设备模型</a>
|
||||
</div>
|
||||
<form method="post" action="/ui/devices/{{.DeviceID}}/models/upload" enctype="multipart/form-data" class="row compact-form">
|
||||
<input name="name" placeholder="模型名" />
|
||||
<input type="file" name="file" />
|
||||
<button type="submit" class="btn ghost">上传模型</button>
|
||||
</form>
|
||||
</td>
|
||||
<td><a class="btn ghost" href="/ui/devices/{{.DeviceID}}/config-ui">人脸库</a></td>
|
||||
<td class="mono">待上报</td>
|
||||
<td class="mono">待上报</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="muted">暂无设备。请先在“新增设备”页扫描或手动添加。</td></tr>
|
||||
<tr><td colspan="5" class="muted">暂无设备。请先在“设备”页扫描或手动添加。</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
43
internal/web/ui/templates/resources.html
Normal file
43
internal/web/ui/templates/resources.html
Normal file
@ -0,0 +1,43 @@
|
||||
{{define "resources"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>资源管理</h2>
|
||||
<div class="muted small">统一维护人脸库与通用资源,设备侧只显示当前版本与同步状态。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-summary">
|
||||
<div class="summary-item"><div class="summary-label">人脸库版本</div><div class="summary-value">统一管理</div><div class="summary-hint">由平台集中维护与发布</div></div>
|
||||
<div class="summary-item"><div class="summary-label">通用资源</div><div class="summary-value">统一管理</div><div class="summary-hint">标定、字典与业务资源统一维护</div></div>
|
||||
<div class="summary-item"><div class="summary-label">设备资源状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备资源版本覆盖</div></div>
|
||||
<div class="summary-item"><div class="summary-label">同步方式</div><div class="summary-value">任务下发</div><div class="summary-hint">与场景配置、模型同步一致</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>设备资源状态</h2>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>节点</th><th>状态</th><th>管理地址</th><th>人脸库版本</th><th>资源状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
|
||||
<div class="muted small mono">{{.DeviceID}}</div>
|
||||
</td>
|
||||
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
|
||||
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
|
||||
<td class="mono">待上报</td>
|
||||
<td>{{if .Online}}<span class="pill warn">待同步</span>{{else}}<span class="pill bad">设备离线</span>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="muted">暂无设备。请先在“设备”页扫描或手动添加。</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -1,6 +1,6 @@
|
||||
{{define "system"}}
|
||||
<div class="card">
|
||||
<div class="crumb">诊断 / 系统状态</div>
|
||||
<div class="crumb">系统管理 / 系统状态</div>
|
||||
<div class="muted small" style="margin-top:8px">系统状态页负责平台健康、发现机制和接入策略查看。</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
30
scripts/restart.bat
Normal file
30
scripts/restart.bat
Normal file
@ -0,0 +1,30 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo ^> 编译 managerd.exe ...
|
||||
go build -o managerd.exe ./cmd/managerd
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo 编译失败,请检查代码错误
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ^> 停止正在运行的 managerd.exe ...
|
||||
taskkill /f /im managerd.exe 2>nul
|
||||
|
||||
echo ^> 启动 managerd.exe ...
|
||||
start /b "" managerd.exe
|
||||
|
||||
echo ^> 等待启动 ...
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
echo ^> 检查进程状态 ...
|
||||
tasklist /fi "imagename eq managerd.exe" 2>nul | findstr /i managerd >nul
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
echo managerd 已启动
|
||||
) else (
|
||||
echo 启动失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
214
templates/standard_templates/std_face_recognition_stream.json
Normal file
214
templates/standard_templates/std_face_recognition_stream.json
Normal file
@ -0,0 +1,214 @@
|
||||
{
|
||||
"name": "std_face_recognition_stream",
|
||||
"description": "1080p 人脸识别流程,包含滑窗人脸检测、人脸识别、画面叠加与视频发布。",
|
||||
"source": "configs/test_scrfd_640_recog.json",
|
||||
"slots": {
|
||||
"inputs": [
|
||||
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
|
||||
],
|
||||
"services": [],
|
||||
"outputs": [
|
||||
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
|
||||
]
|
||||
},
|
||||
"template": {
|
||||
"executor": {
|
||||
"batch_size": 2,
|
||||
"run_budget": 6
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "input_rtsp_main",
|
||||
"type": "input_rtsp",
|
||||
"role": "source",
|
||||
"enable": true,
|
||||
"url": "${slot:video_input_main.url}",
|
||||
"fps": 30,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"use_ffmpeg": true,
|
||||
"use_mpp": false,
|
||||
"force_tcp": true,
|
||||
"reconnect_sec": 5,
|
||||
"reconnect_backoff_max_sec": 30
|
||||
},
|
||||
{
|
||||
"id": "preprocess_rgb",
|
||||
"type": "preprocess",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
2
|
||||
],
|
||||
"dst_w": 1920,
|
||||
"dst_h": 1080,
|
||||
"dst_format": "rgb",
|
||||
"dst_packed": true,
|
||||
"resize_mode": "stretch",
|
||||
"keep_ratio": false,
|
||||
"rga_gate": "face_recognition_pipeline_rga",
|
||||
"use_rga": true
|
||||
},
|
||||
{
|
||||
"id": "detect_face",
|
||||
"type": "ai_scrfd_sliding",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
4
|
||||
],
|
||||
"infer_fps": 3,
|
||||
"model_path": "./models/face_det_scrfd_500m_640_rk3588.rknn",
|
||||
"model_w": 640,
|
||||
"model_h": 640,
|
||||
"windows": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 960,
|
||||
"h": 1080
|
||||
},
|
||||
{
|
||||
"x": 960,
|
||||
"y": 0,
|
||||
"w": 960,
|
||||
"h": 1080
|
||||
}
|
||||
],
|
||||
"conf_thresh": 0.5,
|
||||
"nms_thresh": 0.4,
|
||||
"max_faces": 50,
|
||||
"debug": {
|
||||
"stats": false,
|
||||
"stats_interval": 30
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "recognize_face",
|
||||
"type": "ai_face_recog",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
4
|
||||
],
|
||||
"infer_fps": 2,
|
||||
"infer_phase_ms": 120,
|
||||
"model_path": "./models/face_recog_mobilefacenet_arcface_112_rk3588.rknn",
|
||||
"align": true,
|
||||
"emit_embedding": false,
|
||||
"max_faces": 50,
|
||||
"person_class_id": 0,
|
||||
"track_state_max_age_ms": 1000,
|
||||
"input_format": "rgb",
|
||||
"input_dtype": "uint8",
|
||||
"threshold": {
|
||||
"accept": 0.45,
|
||||
"margin": 0.05
|
||||
},
|
||||
"gallery": {
|
||||
"backend": "sqlite",
|
||||
"path": "./models/face_gallery.db",
|
||||
"load_on_start": true,
|
||||
"dtype": "auto"
|
||||
},
|
||||
"debug": {
|
||||
"enabled": false,
|
||||
"log_matches": false,
|
||||
"min_log_interval_ms": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "render_osd",
|
||||
"type": "osd",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
7
|
||||
],
|
||||
"draw_bbox": true,
|
||||
"draw_text": true,
|
||||
"draw_face_det": true,
|
||||
"draw_face_recog": true,
|
||||
"draw_face_bbox": true,
|
||||
"line_width": 2.0,
|
||||
"font_scale": 1.0,
|
||||
"use_rga_bbox": false,
|
||||
"labels": []
|
||||
},
|
||||
{
|
||||
"id": "prepare_publish",
|
||||
"type": "preprocess",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
7
|
||||
],
|
||||
"dst_w": 1920,
|
||||
"dst_h": 1080,
|
||||
"dst_format": "nv12",
|
||||
"resize_mode": "stretch",
|
||||
"rga_gate": "face_recognition_pipeline_rga",
|
||||
"use_rga": true
|
||||
},
|
||||
{
|
||||
"id": "publish_stream",
|
||||
"type": "publish",
|
||||
"role": "sink",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
3
|
||||
],
|
||||
"queue": {
|
||||
"size": 2,
|
||||
"policy": "drop_oldest"
|
||||
},
|
||||
"codec": "h264",
|
||||
"fps": 30,
|
||||
"gop": 60,
|
||||
"bitrate_kbps": 4000,
|
||||
"mpp_output_timeout_ms": 50,
|
||||
"mpp_packet_wait_ms": 10,
|
||||
"use_mpp": true,
|
||||
"use_ffmpeg_mux": true,
|
||||
"outputs": [
|
||||
{
|
||||
"proto": "hls",
|
||||
"path": "${slot:stream_output_main.publish_hls_path}",
|
||||
"segment_sec": 2
|
||||
},
|
||||
{
|
||||
"proto": "rtsp_server",
|
||||
"port": "${slot:stream_output_main.publish_rtsp_port}",
|
||||
"path": "${slot:stream_output_main.publish_rtsp_path}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
"input_rtsp_main",
|
||||
"preprocess_rgb"
|
||||
],
|
||||
[
|
||||
"preprocess_rgb",
|
||||
"detect_face"
|
||||
],
|
||||
[
|
||||
"detect_face",
|
||||
"recognize_face"
|
||||
],
|
||||
[
|
||||
"recognize_face",
|
||||
"render_osd"
|
||||
],
|
||||
[
|
||||
"render_osd",
|
||||
"prepare_publish"
|
||||
],
|
||||
[
|
||||
"prepare_publish",
|
||||
"publish_stream"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
83
templates/standard_templates/std_service_test_stream.json
Normal file
83
templates/standard_templates/std_service_test_stream.json
Normal file
@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "std_service_test_stream",
|
||||
"description": "最简服务可用性测试流程,用于验证视频解码、预处理、编码与发布链路是否正常。",
|
||||
"source": "configs/test_face_only.json",
|
||||
"slots": {
|
||||
"inputs": [
|
||||
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
|
||||
],
|
||||
"services": [],
|
||||
"outputs": [
|
||||
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
|
||||
]
|
||||
},
|
||||
"template": {
|
||||
"executor": {
|
||||
"batch_size": 1,
|
||||
"run_budget": 3
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "input_rtsp_main",
|
||||
"type": "input_rtsp",
|
||||
"role": "source",
|
||||
"enable": true,
|
||||
"url": "${slot:video_input_main.url}",
|
||||
"fps": 25,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"use_ffmpeg": true,
|
||||
"use_mpp": false,
|
||||
"force_tcp": true,
|
||||
"reconnect_sec": 5,
|
||||
"reconnect_backoff_max_sec": 30
|
||||
},
|
||||
{
|
||||
"id": "prepare_publish",
|
||||
"type": "preprocess",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"dst_w": 1280,
|
||||
"dst_h": 720,
|
||||
"dst_format": "nv12",
|
||||
"resize_mode": "stretch",
|
||||
"use_rga": true,
|
||||
"rga_gate": "service_test_pipeline_rga"
|
||||
},
|
||||
{
|
||||
"id": "publish_stream",
|
||||
"type": "publish",
|
||||
"role": "sink",
|
||||
"enable": true,
|
||||
"codec": "h264",
|
||||
"fps": 25,
|
||||
"gop": 50,
|
||||
"bitrate_kbps": 1500,
|
||||
"use_mpp": true,
|
||||
"use_ffmpeg_mux": true,
|
||||
"outputs": [
|
||||
{
|
||||
"proto": "hls",
|
||||
"path": "${slot:stream_output_main.publish_hls_path}",
|
||||
"segment_sec": 2
|
||||
},
|
||||
{
|
||||
"proto": "rtsp_server",
|
||||
"port": "${slot:stream_output_main.publish_rtsp_port}",
|
||||
"path": "${slot:stream_output_main.publish_rtsp_path}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
"input_rtsp_main",
|
||||
"prepare_publish"
|
||||
],
|
||||
[
|
||||
"prepare_publish",
|
||||
"publish_stream"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
302
templates/standard_templates/std_workshoe_detection_stream.json
Normal file
302
templates/standard_templates/std_workshoe_detection_stream.json
Normal file
@ -0,0 +1,302 @@
|
||||
{
|
||||
"name": "std_workshoe_detection_stream",
|
||||
"description": "1080p 劳保鞋检测流程,包含人员检测、人员跟踪、劳保鞋规则判断、画面叠加与视频发布。",
|
||||
"source": "configs/full_pipeline_1080p_test_alarm.json",
|
||||
"slots": {
|
||||
"inputs": [
|
||||
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
|
||||
],
|
||||
"services": [],
|
||||
"outputs": [
|
||||
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
|
||||
]
|
||||
},
|
||||
"template": {
|
||||
"executor": {
|
||||
"batch_size": 2,
|
||||
"run_budget": 6
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "input_rtsp_main",
|
||||
"type": "input_rtsp",
|
||||
"role": "source",
|
||||
"enable": true,
|
||||
"url": "${slot:video_input_main.url}",
|
||||
"fps": 30,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"use_ffmpeg": true,
|
||||
"use_mpp": false,
|
||||
"force_tcp": true,
|
||||
"reconnect_sec": 5,
|
||||
"reconnect_backoff_max_sec": 30
|
||||
},
|
||||
{
|
||||
"id": "preprocess_rgb",
|
||||
"type": "preprocess",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
2
|
||||
],
|
||||
"dst_w": 1920,
|
||||
"dst_h": 1080,
|
||||
"dst_format": "rgb",
|
||||
"dst_packed": true,
|
||||
"resize_mode": "stretch",
|
||||
"keep_ratio": false,
|
||||
"rga_gate": "workshoe_detection_pipeline_rga",
|
||||
"use_rga": true
|
||||
},
|
||||
{
|
||||
"id": "detect_person",
|
||||
"type": "ai_yolo",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
5
|
||||
],
|
||||
"use_rga": true,
|
||||
"rga_gate": "workshoe_detection_pipeline_rga",
|
||||
"rga_max_inflight": 4,
|
||||
"dst_packed": true,
|
||||
"use_dma_input": true,
|
||||
"infer_fps": 2,
|
||||
"infer_phase_ms": 0,
|
||||
"model_path": "./models/object_det_yolov8n_coco_640_rk3588.rknn",
|
||||
"model_version": "v8",
|
||||
"model_w": 640,
|
||||
"model_h": 640,
|
||||
"num_classes": 80,
|
||||
"conf": 0.35,
|
||||
"nms": 0.45,
|
||||
"class_filter": [
|
||||
0
|
||||
],
|
||||
"bbox_expand": {
|
||||
"enable": true,
|
||||
"class_id": 0,
|
||||
"left": 0.06,
|
||||
"right": 0.06,
|
||||
"top": 0.04,
|
||||
"bottom": 0.16
|
||||
},
|
||||
"debug": {
|
||||
"stats": false,
|
||||
"stats_interval": 30,
|
||||
"detections": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "track_person",
|
||||
"type": "tracker",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
5
|
||||
],
|
||||
"mode": "bytetrack_lite",
|
||||
"per_class": true,
|
||||
"track_classes": [
|
||||
0
|
||||
],
|
||||
"ignore_classes": [],
|
||||
"high_th": 0.55,
|
||||
"low_th": 0.1,
|
||||
"iou_th": 0.3,
|
||||
"max_age_ms": 900,
|
||||
"min_hits": 1,
|
||||
"max_tracks": 128
|
||||
},
|
||||
{
|
||||
"id": "detect_shoe",
|
||||
"type": "ai_shoe_det",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
6
|
||||
],
|
||||
"use_rga": true,
|
||||
"rga_gate": "workshoe_detection_pipeline_rga",
|
||||
"rga_max_inflight": 4,
|
||||
"dst_packed": true,
|
||||
"use_dma_input": false,
|
||||
"infer_fps": 2,
|
||||
"infer_phase_ms": 150,
|
||||
"model_path": "./models/shoe_det_yolov8s_workshoe_640_rk3588.rknn",
|
||||
"model_w": 640,
|
||||
"model_h": 640,
|
||||
"conf": 0.22,
|
||||
"nms": 0.45,
|
||||
"v8_box_format": "cxcywh",
|
||||
"append_detections": true,
|
||||
"dynamic_roi": {
|
||||
"enable": true,
|
||||
"person_class_id": 0,
|
||||
"shoe_class_id": 1,
|
||||
"debug_roi_class_id": -1,
|
||||
"max_rois": 3,
|
||||
"min_person_height": 60,
|
||||
"max_box_area_ratio": 0.6,
|
||||
"y_offset": 0.7,
|
||||
"width_scale": 1.6,
|
||||
"height_scale": 0.4
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rule_shoe_association",
|
||||
"type": "logic_gate",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
6
|
||||
],
|
||||
"mode": "person_shoe_check",
|
||||
"debug": false,
|
||||
"person_shoe_check": {
|
||||
"person_class": 0,
|
||||
"shoe_class": 1,
|
||||
"violation_class": 2,
|
||||
"min_person_score": 0.3,
|
||||
"min_shoe_score": 0.22,
|
||||
"foot_region": {
|
||||
"y_offset": 0.7,
|
||||
"width_scale": 1.6,
|
||||
"height_scale": 0.4
|
||||
},
|
||||
"min_shoe_height_ratio": 0.08,
|
||||
"min_shoe_area_ratio": 0.012,
|
||||
"max_shoe_height_ratio": 0.14,
|
||||
"max_shoe_width_ratio": 0.38,
|
||||
"max_shoe_area_ratio": 0.05,
|
||||
"max_shoe_roi_width_ratio": 0.45,
|
||||
"max_shoe_roi_height_ratio": 0.35,
|
||||
"max_shoe_roi_area_ratio": 0.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rule_shoe_color",
|
||||
"type": "logic_gate",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
6
|
||||
],
|
||||
"mode": "ppe_boots_check",
|
||||
"anchor_class": 0,
|
||||
"boots_class": 1,
|
||||
"violation_class": 2,
|
||||
"debug": false,
|
||||
"color_check": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "render_osd",
|
||||
"type": "osd",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
7
|
||||
],
|
||||
"draw_bbox": true,
|
||||
"draw_text": true,
|
||||
"line_width": 2.0,
|
||||
"font_scale": 1.0,
|
||||
"use_rga_bbox": false,
|
||||
"labels": [
|
||||
"person",
|
||||
"shoe",
|
||||
"non_black_shoe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prepare_publish",
|
||||
"type": "preprocess",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
7
|
||||
],
|
||||
"dst_w": 1920,
|
||||
"dst_h": 1080,
|
||||
"dst_format": "nv12",
|
||||
"resize_mode": "stretch",
|
||||
"rga_gate": "workshoe_detection_pipeline_rga",
|
||||
"use_rga": true
|
||||
},
|
||||
{
|
||||
"id": "publish_stream",
|
||||
"type": "publish",
|
||||
"role": "sink",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
3
|
||||
],
|
||||
"queue": {
|
||||
"size": 2,
|
||||
"policy": "drop_oldest"
|
||||
},
|
||||
"codec": "h264",
|
||||
"fps": 30,
|
||||
"gop": 60,
|
||||
"bitrate_kbps": 4000,
|
||||
"mpp_output_timeout_ms": 50,
|
||||
"mpp_packet_wait_ms": 10,
|
||||
"use_mpp": true,
|
||||
"use_ffmpeg_mux": true,
|
||||
"outputs": [
|
||||
{
|
||||
"proto": "hls",
|
||||
"path": "${slot:stream_output_main.publish_hls_path}",
|
||||
"segment_sec": 2
|
||||
},
|
||||
{
|
||||
"proto": "rtsp_server",
|
||||
"port": "${slot:stream_output_main.publish_rtsp_port}",
|
||||
"path": "${slot:stream_output_main.publish_rtsp_path}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
"input_rtsp_main",
|
||||
"preprocess_rgb"
|
||||
],
|
||||
[
|
||||
"preprocess_rgb",
|
||||
"detect_person"
|
||||
],
|
||||
[
|
||||
"detect_person",
|
||||
"track_person"
|
||||
],
|
||||
[
|
||||
"track_person",
|
||||
"detect_shoe"
|
||||
],
|
||||
[
|
||||
"detect_shoe",
|
||||
"rule_shoe_association"
|
||||
],
|
||||
[
|
||||
"rule_shoe_association",
|
||||
"rule_shoe_color"
|
||||
],
|
||||
[
|
||||
"rule_shoe_color",
|
||||
"render_osd"
|
||||
],
|
||||
[
|
||||
"render_osd",
|
||||
"prepare_publish"
|
||||
],
|
||||
[
|
||||
"prepare_publish",
|
||||
"publish_stream"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,552 @@
|
||||
{
|
||||
"name": "std_workshop_face_recognition_shoe_alarm",
|
||||
"description": "1080p 车间全功能识别流程,包含人脸检测与识别、人员跟踪、劳保鞋检测、画面叠加、视频发布、告警上报以及抓拍存证。",
|
||||
"source": "configs/full_pipeline_1080p_test_alarm.json",
|
||||
"slots": {
|
||||
"inputs": [
|
||||
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
|
||||
],
|
||||
"services": [
|
||||
{"name": "object_storage_main", "type": "object_storage", "required": false, "description": "抓拍与片段上传"},
|
||||
{"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"},
|
||||
{"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
|
||||
]
|
||||
},
|
||||
"template": {
|
||||
"executor": {
|
||||
"batch_size": 2,
|
||||
"run_budget": 8
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "input_rtsp_main",
|
||||
"type": "input_rtsp",
|
||||
"role": "source",
|
||||
"enable": true,
|
||||
"url": "${slot:video_input_main.url}",
|
||||
"fps": 30,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"use_ffmpeg": true,
|
||||
"use_mpp": false,
|
||||
"force_tcp": true,
|
||||
"reconnect_sec": 5,
|
||||
"reconnect_backoff_max_sec": 30
|
||||
},
|
||||
{
|
||||
"id": "preprocess_rgb",
|
||||
"type": "preprocess",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
2
|
||||
],
|
||||
"dst_w": 1920,
|
||||
"dst_h": 1080,
|
||||
"dst_format": "rgb",
|
||||
"dst_packed": true,
|
||||
"resize_mode": "stretch",
|
||||
"keep_ratio": false,
|
||||
"rga_gate": "main_pipeline_rga",
|
||||
"use_rga": true
|
||||
},
|
||||
{
|
||||
"id": "detect_face",
|
||||
"type": "ai_scrfd_sliding",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
4
|
||||
],
|
||||
"infer_fps": 3,
|
||||
"model_path": "./models/face_det_scrfd_500m_640_rk3588.rknn",
|
||||
"model_w": 640,
|
||||
"model_h": 640,
|
||||
"windows": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 960,
|
||||
"h": 1080
|
||||
},
|
||||
{
|
||||
"x": 960,
|
||||
"y": 0,
|
||||
"w": 960,
|
||||
"h": 1080
|
||||
}
|
||||
],
|
||||
"conf_thresh": 0.5,
|
||||
"nms_thresh": 0.4,
|
||||
"max_faces": 50,
|
||||
"debug": {
|
||||
"stats": false,
|
||||
"stats_interval": 30
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "recognize_face",
|
||||
"type": "ai_face_recog",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
4
|
||||
],
|
||||
"infer_fps": 2,
|
||||
"infer_phase_ms": 120,
|
||||
"model_path": "./models/face_recog_mobilefacenet_arcface_112_rk3588.rknn",
|
||||
"align": true,
|
||||
"emit_embedding": false,
|
||||
"max_faces": 50,
|
||||
"person_class_id": 0,
|
||||
"track_state_max_age_ms": 1000,
|
||||
"input_format": "rgb",
|
||||
"input_dtype": "uint8",
|
||||
"threshold": {
|
||||
"accept": 0.45,
|
||||
"margin": 0.05
|
||||
},
|
||||
"gallery": {
|
||||
"backend": "sqlite",
|
||||
"path": "./models/face_gallery.db",
|
||||
"load_on_start": true,
|
||||
"dtype": "auto"
|
||||
},
|
||||
"debug": {
|
||||
"enabled": false,
|
||||
"log_matches": false,
|
||||
"min_log_interval_ms": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "detect_person",
|
||||
"type": "ai_yolo",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
5
|
||||
],
|
||||
"use_rga": true,
|
||||
"rga_gate": "main_pipeline_rga",
|
||||
"rga_max_inflight": 4,
|
||||
"dst_packed": true,
|
||||
"use_dma_input": true,
|
||||
"infer_fps": 2,
|
||||
"infer_phase_ms": 0,
|
||||
"model_path": "./models/object_det_yolov8n_coco_640_rk3588.rknn",
|
||||
"model_version": "v8",
|
||||
"model_w": 640,
|
||||
"model_h": 640,
|
||||
"num_classes": 80,
|
||||
"conf": 0.35,
|
||||
"nms": 0.45,
|
||||
"class_filter": [
|
||||
0
|
||||
],
|
||||
"bbox_expand": {
|
||||
"enable": true,
|
||||
"class_id": 0,
|
||||
"left": 0.06,
|
||||
"right": 0.06,
|
||||
"top": 0.04,
|
||||
"bottom": 0.16
|
||||
},
|
||||
"debug": {
|
||||
"stats": false,
|
||||
"stats_interval": 30,
|
||||
"detections": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "track_person",
|
||||
"type": "tracker",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
5
|
||||
],
|
||||
"mode": "bytetrack_lite",
|
||||
"per_class": true,
|
||||
"track_classes": [
|
||||
0
|
||||
],
|
||||
"ignore_classes": [],
|
||||
"high_th": 0.55,
|
||||
"low_th": 0.1,
|
||||
"iou_th": 0.3,
|
||||
"max_age_ms": 900,
|
||||
"min_hits": 1,
|
||||
"max_tracks": 128
|
||||
},
|
||||
{
|
||||
"id": "detect_shoe",
|
||||
"type": "ai_shoe_det",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
6
|
||||
],
|
||||
"use_rga": true,
|
||||
"rga_gate": "main_pipeline_rga",
|
||||
"rga_max_inflight": 4,
|
||||
"dst_packed": true,
|
||||
"use_dma_input": false,
|
||||
"infer_fps": 2,
|
||||
"infer_phase_ms": 150,
|
||||
"model_path": "./models/shoe_det_yolov8s_workshoe_640_rk3588.rknn",
|
||||
"model_w": 640,
|
||||
"model_h": 640,
|
||||
"conf": 0.22,
|
||||
"nms": 0.45,
|
||||
"v8_box_format": "cxcywh",
|
||||
"append_detections": true,
|
||||
"dynamic_roi": {
|
||||
"enable": true,
|
||||
"person_class_id": 0,
|
||||
"shoe_class_id": 1,
|
||||
"debug_roi_class_id": -1,
|
||||
"max_rois": 3,
|
||||
"min_person_height": 60,
|
||||
"max_box_area_ratio": 0.6,
|
||||
"y_offset": 0.7,
|
||||
"width_scale": 1.6,
|
||||
"height_scale": 0.4
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rule_shoe_association",
|
||||
"type": "logic_gate",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
6
|
||||
],
|
||||
"mode": "person_shoe_check",
|
||||
"debug": false,
|
||||
"person_shoe_check": {
|
||||
"person_class": 0,
|
||||
"shoe_class": 1,
|
||||
"violation_class": 2,
|
||||
"min_person_score": 0.3,
|
||||
"min_shoe_score": 0.22,
|
||||
"foot_region": {
|
||||
"y_offset": 0.7,
|
||||
"width_scale": 1.6,
|
||||
"height_scale": 0.4
|
||||
},
|
||||
"min_shoe_height_ratio": 0.08,
|
||||
"min_shoe_area_ratio": 0.012,
|
||||
"max_shoe_height_ratio": 0.14,
|
||||
"max_shoe_width_ratio": 0.38,
|
||||
"max_shoe_area_ratio": 0.05,
|
||||
"max_shoe_roi_width_ratio": 0.45,
|
||||
"max_shoe_roi_height_ratio": 0.35,
|
||||
"max_shoe_roi_area_ratio": 0.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rule_shoe_color",
|
||||
"type": "logic_gate",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
6
|
||||
],
|
||||
"mode": "ppe_boots_check",
|
||||
"anchor_class": 0,
|
||||
"boots_class": 1,
|
||||
"violation_class": 2,
|
||||
"debug": false,
|
||||
"color_check": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "prepare_publish",
|
||||
"type": "preprocess",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
7
|
||||
],
|
||||
"dst_w": 1920,
|
||||
"dst_h": 1080,
|
||||
"dst_format": "nv12",
|
||||
"resize_mode": "stretch",
|
||||
"rga_gate": "main_pipeline_rga",
|
||||
"use_rga": true
|
||||
},
|
||||
{
|
||||
"id": "render_osd",
|
||||
"type": "osd",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
7
|
||||
],
|
||||
"draw_bbox": true,
|
||||
"draw_text": true,
|
||||
"draw_face_det": true,
|
||||
"draw_face_recog": true,
|
||||
"draw_face_bbox": true,
|
||||
"line_width": 2.0,
|
||||
"font_scale": 1.0,
|
||||
"use_rga_bbox": false,
|
||||
"labels": [
|
||||
"person",
|
||||
"shoe",
|
||||
"non_black_shoe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "publish_stream",
|
||||
"type": "publish",
|
||||
"role": "filter",
|
||||
"enable": true,
|
||||
"cpu_affinity": [
|
||||
3
|
||||
],
|
||||
"queue": {
|
||||
"size": 2,
|
||||
"policy": "drop_oldest"
|
||||
},
|
||||
"codec": "h264",
|
||||
"fps": 30,
|
||||
"gop": 60,
|
||||
"bitrate_kbps": 4000,
|
||||
"mpp_output_timeout_ms": 50,
|
||||
"mpp_packet_wait_ms": 10,
|
||||
"use_mpp": true,
|
||||
"use_ffmpeg_mux": true,
|
||||
"outputs": [
|
||||
{
|
||||
"proto": "hls",
|
||||
"path": "${slot:stream_output_main.publish_hls_path}",
|
||||
"segment_sec": 2
|
||||
},
|
||||
{
|
||||
"proto": "rtsp_server",
|
||||
"port": "${slot:stream_output_main.publish_rtsp_port}",
|
||||
"path": "${slot:stream_output_main.publish_rtsp_path}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alarm_violation",
|
||||
"type": "alarm",
|
||||
"role": "sink",
|
||||
"enable": true,
|
||||
"eval_fps": 2,
|
||||
"labels": [
|
||||
"person",
|
||||
"shoe",
|
||||
"non_black_shoe"
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"name": "non_compliant_workshoe",
|
||||
"class_ids": [
|
||||
2
|
||||
],
|
||||
"roi": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"w": 1.0,
|
||||
"h": 1.0
|
||||
},
|
||||
"min_score": 0.3,
|
||||
"min_box_area_ratio": 0.0,
|
||||
"require_track_id": false,
|
||||
"min_duration_ms": 800,
|
||||
"min_hits": 2,
|
||||
"hit_window_ms": 2000,
|
||||
"cooldown_ms": 15000,
|
||||
"per_track_cooldown_ms": 0
|
||||
}
|
||||
],
|
||||
"face_track_aggregation": {
|
||||
"known": {
|
||||
"min_hits": 3,
|
||||
"hit_window_ms": 3000,
|
||||
"reentry_cooldown_ms": 300000
|
||||
},
|
||||
"unknown": {
|
||||
"min_track_age_ms": 2000,
|
||||
"min_quality_hits": 4
|
||||
}
|
||||
},
|
||||
"face_debug": {
|
||||
"log_unknown_candidates": false,
|
||||
"unknown_candidate_interval_ms": 0
|
||||
},
|
||||
"face_rules": [
|
||||
{
|
||||
"name": "unknown_face",
|
||||
"type": "unknown",
|
||||
"cooldown_ms": 7000,
|
||||
"max_known_sim": 0.35,
|
||||
"min_hits": 2,
|
||||
"hit_window_ms": 1500,
|
||||
"min_face_area_ratio": 0.001,
|
||||
"min_face_aspect": 0.6,
|
||||
"max_face_aspect": 1.6
|
||||
},
|
||||
{
|
||||
"name": "known_person",
|
||||
"type": "person",
|
||||
"cooldown_ms": 7000,
|
||||
"min_sim": 0.6,
|
||||
"min_hits": 2,
|
||||
"hit_window_ms": 1500,
|
||||
"min_face_area_ratio": 0.001,
|
||||
"min_face_aspect": 0.6,
|
||||
"max_face_aspect": 1.6
|
||||
}
|
||||
],
|
||||
"actions": {
|
||||
"log": {
|
||||
"enable": true,
|
||||
"level": "info",
|
||||
"include_detections": true,
|
||||
"min_interval_ms": 2000
|
||||
},
|
||||
"snapshot": {
|
||||
"enable": true,
|
||||
"format": "jpg",
|
||||
"quality": 85,
|
||||
"upload": {
|
||||
"type": "minio",
|
||||
"endpoint": "${slot:object_storage_main.endpoint}",
|
||||
"bucket": "${slot:object_storage_main.bucket}",
|
||||
"region": "us-east-1",
|
||||
"access_key": "${slot:object_storage_main.access_key}",
|
||||
"secret_key": "${slot:object_storage_main.secret_key}"
|
||||
}
|
||||
},
|
||||
"clip": {
|
||||
"enable": true,
|
||||
"pre_sec": 5,
|
||||
"post_sec": 10,
|
||||
"format": "mp4",
|
||||
"fps": 30,
|
||||
"upload": {
|
||||
"type": "minio",
|
||||
"endpoint": "${slot:object_storage_main.endpoint}",
|
||||
"bucket": "${slot:object_storage_main.bucket}",
|
||||
"region": "us-east-1",
|
||||
"access_key": "${slot:object_storage_main.access_key}",
|
||||
"secret_key": "${slot:object_storage_main.secret_key}"
|
||||
}
|
||||
},
|
||||
"external_api": {
|
||||
"enable": true,
|
||||
"getTokenUrl": "${slot:token_service_main.get_token_url}",
|
||||
"putMessageUrl": "${slot:alarm_service_main.put_message_url}",
|
||||
"tenantCode": "${slot:alarm_service_main.tenant_code}",
|
||||
"channelNo": "${slot:stream_output_main.channel_no}",
|
||||
"timeout_ms": 3000,
|
||||
"include_media_url": true,
|
||||
"token_header": "X-Access-Token",
|
||||
"token_json_path": "responseBody.token",
|
||||
"token_cache_sec": 1200
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
"input_rtsp_main",
|
||||
"preprocess_rgb"
|
||||
],
|
||||
[
|
||||
"preprocess_rgb",
|
||||
"detect_face"
|
||||
],
|
||||
[
|
||||
"detect_face",
|
||||
"detect_person"
|
||||
],
|
||||
[
|
||||
"detect_person",
|
||||
"track_person"
|
||||
],
|
||||
[
|
||||
"track_person",
|
||||
"recognize_face"
|
||||
],
|
||||
[
|
||||
"recognize_face",
|
||||
"detect_shoe",
|
||||
{
|
||||
"queue": {
|
||||
"size": 16,
|
||||
"strategy": "drop_oldest"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"detect_shoe",
|
||||
"rule_shoe_association",
|
||||
{
|
||||
"queue": {
|
||||
"size": 16,
|
||||
"strategy": "drop_oldest"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"rule_shoe_association",
|
||||
"rule_shoe_color",
|
||||
{
|
||||
"queue": {
|
||||
"size": 16,
|
||||
"strategy": "drop_oldest"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"rule_shoe_color",
|
||||
"render_osd",
|
||||
{
|
||||
"queue": {
|
||||
"size": 16,
|
||||
"strategy": "drop_oldest"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"render_osd",
|
||||
"prepare_publish",
|
||||
{
|
||||
"queue": {
|
||||
"size": 32,
|
||||
"strategy": "drop_oldest"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"prepare_publish",
|
||||
"publish_stream",
|
||||
{
|
||||
"queue": {
|
||||
"size": 64,
|
||||
"strategy": "drop_oldest"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"publish_stream",
|
||||
"alarm_violation",
|
||||
{
|
||||
"queue": {
|
||||
"size": 64,
|
||||
"strategy": "drop_oldest"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user