Refactor config management flow and admin IA

This commit is contained in:
tian 2026-04-29 18:59:57 +08:00
parent 33d5f6f8ab
commit 328705ebc6
43 changed files with 5306 additions and 1191 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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"])
}

View File

@ -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)
}
}

View 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
}

View File

@ -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
}

View 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
}

View 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
}

View 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)
}
}

View File

@ -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 = ?
`

View File

@ -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)
}
}

View File

@ -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, &notnull, &dfltValue, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}

View File

@ -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)
}
}

View File

@ -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"),

View File

@ -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 {

View File

@ -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" },

View File

@ -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}

View File

@ -1,6 +1,6 @@
{{define "api"}}
<div class="card">
<div class="crumb">诊断 / 高级调试</div>
<div class="crumb">系统管理 / 日志审计 / 高级调试</div>
<h2>高级调试</h2>
</div>

View File

@ -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>

View File

@ -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>

View File

@ -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}}

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}}

View File

@ -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>

View File

@ -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>

View 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}}

View File

@ -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
View 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
)

View 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"
]
]
}
}

View 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"
]
]
}
}

View 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"
]
]
}
}

View File

@ -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"
}
}
]
]
}
}