diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index 2fcaa34..e91863e 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -39,6 +39,9 @@ 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"` @@ -69,12 +72,39 @@ type ConfigOverlayAsset struct { } type ConfigIntegrationServiceAsset struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + TypeLabel string `json:"type_label"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + AddressSummary string `json:"address_summary"` + RefCount int `json:"ref_count"` + ObjectStorage *ObjectStorageConfig `json:"object_storage,omitempty"` + TokenService *TokenServiceConfig `json:"token_service,omitempty"` + AlarmService *AlarmServiceConfig `json:"alarm_service,omitempty"` + Raw map[string]any `json:"raw"` +} + +type ObjectStorageConfig struct { + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` +} + +type TokenServiceConfig struct { + GetTokenURL string `json:"get_token_url"` + Username string `json:"username"` + Password string `json:"password"` + TenantCode string `json:"tenant_code"` +} + +type AlarmServiceConfig struct { + PutMessageURL string `json:"put_message_url"` + Username string `json:"username"` + Password string `json:"password"` + TenantCode string `json:"tenant_code"` } func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) { @@ -280,14 +310,17 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset }) } return &ConfigProfileAsset{ - 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, + 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, }, nil } @@ -343,8 +376,12 @@ func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationSer if err != nil { continue } + if refs, err := s.profileNamesReferencingIntegrationService(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 } @@ -362,7 +399,63 @@ func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegr if record == nil { return nil, os.ErrNotExist } - return integrationServiceAssetFromRecord(*record) + item, err := integrationServiceAssetFromRecord(*record) + if err != nil { + return nil, err + } + if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { + item.RefCount = len(refs) + } + return item, nil +} + +func (s *ConfigPreviewService) DeleteIntegrationService(name string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return err + } + refs, err := s.profileNamesReferencingIntegrationService(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("third-party service %q is used by scene configs: %s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteIntegrationService(name) +} + +func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(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 + } + if raw == nil { + continue + } + for _, key := range []string{"object_storage_ref", "token_service_ref", "alarm_service_ref"} { + if strings.TrimSpace(stringValue(raw[key])) == name { + refs = append(refs, record.Name) + break + } + } + } + sort.Strings(refs) + return refs, nil } func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) { @@ -521,14 +614,60 @@ func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) if raw == nil { raw = map[string]any{} } - return &ConfigIntegrationServiceAsset{ + configMap, _ := raw["config"].(map[string]any) + sourceMap := raw + if len(configMap) > 0 { + sourceMap = configMap + } + item := &ConfigIntegrationServiceAsset{ Name: firstString(raw["name"], record.Name), Path: repoAssetPath("integration_services", record.Name), Type: firstString(record.ServiceType, stringValue(raw["type"])), Description: firstString(raw["description"], record.Description), Enabled: boolValue(raw["enabled"], record.Enabled), Raw: raw, - }, nil + } + item.TypeLabel = integrationTypeLabel(item.Type) + switch item.Type { + case "object_storage": + item.ObjectStorage = &ObjectStorageConfig{ + Endpoint: stringValue(sourceMap["endpoint"]), + Bucket: stringValue(sourceMap["bucket"]), + AccessKey: firstString(sourceMap["access_key"], stringValue(sourceMap["minio_access_key"])), + SecretKey: firstString(sourceMap["secret_key"], stringValue(sourceMap["minio_secret_key"])), + } + item.AddressSummary = strings.TrimSpace(strings.Trim(strings.Join([]string{item.ObjectStorage.Endpoint, item.ObjectStorage.Bucket}, " / "), " /")) + case "token_service": + item.TokenService = &TokenServiceConfig{ + GetTokenURL: stringValue(sourceMap["get_token_url"]), + Username: stringValue(sourceMap["username"]), + Password: stringValue(sourceMap["password"]), + TenantCode: stringValue(sourceMap["tenant_code"]), + } + item.AddressSummary = item.TokenService.GetTokenURL + case "alarm_service": + item.AlarmService = &AlarmServiceConfig{ + PutMessageURL: stringValue(sourceMap["put_message_url"]), + Username: stringValue(sourceMap["username"]), + Password: stringValue(sourceMap["password"]), + TenantCode: stringValue(sourceMap["tenant_code"]), + } + item.AddressSummary = item.AlarmService.PutMessageURL + } + return item, nil +} + +func integrationTypeLabel(v string) string { + switch strings.TrimSpace(v) { + case "object_storage": + return "对象存储" + case "token_service": + return "认证服务" + case "alarm_service": + return "告警服务" + default: + return strings.TrimSpace(v) + } } func (s *ConfigPreviewService) mediaAssetPath(kind string, name string) (string, bool) { diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index c183942..ed91e1d 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -176,6 +176,9 @@ 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 _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok { t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams) } @@ -189,6 +192,9 @@ 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", @@ -231,6 +237,9 @@ 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) @@ -350,7 +359,7 @@ func TestListIntegrationServices(t *testing.T) { "object_storage", "primary object store", false, - `{"name":"minio_primary","type":"object_storage","provider":"minio","endpoint":"http://10.0.0.49:9000","enabled":false}`, + `{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false,"config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, ); err != nil { t.Fatalf("SaveIntegrationService: %v", err) } @@ -386,7 +395,14 @@ func TestListIntegrationServices(t *testing.T) { if got := stringValue(item.Raw["provider"]); got != "minio" { t.Fatalf("unexpected integration service provider: %#v", item.Raw) } - if got := stringValue(item.Raw["endpoint"]); got != "http://10.0.0.49:9000" { + if item.TypeLabel != "对象存储" || item.AddressSummary != "http://10.0.0.49:9000 / myminio" { + t.Fatalf("unexpected integration display fields: %#v", item) + } + if item.ObjectStorage == nil || item.ObjectStorage.Bucket != "myminio" { + t.Fatalf("expected object storage details, got %#v", item) + } + configMap, _ := item.Raw["config"].(map[string]any) + if got := stringValue(configMap["endpoint"]); got != "http://10.0.0.49:9000" { t.Fatalf("unexpected integration service endpoint: %#v", item.Raw) } if enabled, ok := item.Raw["enabled"].(bool); !ok || enabled { @@ -394,6 +410,37 @@ func TestListIntegrationServices(t *testing.T) { } } +func TestListIntegrationServicesCountsProfileReferences(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.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + true, + `{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, + ); 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 { + t.Fatalf("SaveProfile: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{}, repo) + items, err := svc.ListIntegrationServices() + if err != nil { + t.Fatalf("ListIntegrationServices: %v", err) + } + if len(items) != 1 || items[0].RefCount != 1 { + t.Fatalf("expected one referenced service, got %#v", items) + } +} + func TestGetIntegrationServiceNotFound(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { @@ -442,6 +489,34 @@ func TestGetIntegrationServicePrefersRecordTypeOverRawType(t *testing.T) { } } +func TestDeleteIntegrationServiceBlocksWhenReferenced(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.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + true, + `{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, + ); 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 { + t.Fatalf("SaveProfile: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{}, repo) + err = svc.DeleteIntegrationService("minio_primary") + if err == nil || !strings.Contains(err.Error(), "used by scene configs") { + t.Fatalf("expected referenced delete to be blocked, got %v", err) + } +} + func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go index 4f8241a..1904cae 100644 --- a/internal/service/config_preview.go +++ b/internal/service/config_preview.go @@ -248,6 +248,23 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq 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) + 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") if err != nil { @@ -259,7 +276,7 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq args := []string{ filepath.Join(root, "tools", "render_config.py"), - "--template", templatePath, + "--template", resolvedTemplatePath, "--profile", profilePath, "--out", outPath, "--config-id", req.ConfigID, @@ -302,6 +319,122 @@ 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{} + if raw == nil { + return params, nil + } + type refDef struct { + key string + expected string + apply func(asset *ConfigIntegrationServiceAsset) + } + 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) + }, + }, + } + for _, def := range defs { + refName := strings.TrimSpace(stringValue(raw[def.key])) + if refName == "" { + continue + } + asset, err := s.GetIntegrationService(refName) + if err != nil { + return nil, fmt.Errorf("load %s %q: %w", def.key, refName, err) + } + 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) + } + def.apply(asset) + } + return params, nil +} + +func buildResolvedTemplateFile(templatePath string, params map[string]any) (string, error) { + raw, err := readConfigJSONFile(templatePath) + if err != nil { + return "", err + } + paramsMap, _ := raw["params"].(map[string]any) + if paramsMap == nil { + paramsMap = map[string]any{} + } + for key, value := range params { + paramsMap[key] = value + } + raw["params"] = paramsMap + body, err := marshalConfigJSON(raw) + if err != nil { + return "", err + } + tempTemplate, err := os.CreateTemp("", "rk3588-template-resolved-*.json") + if err != nil { + return "", err + } + tempTemplatePath := tempTemplate.Name() + if _, err := tempTemplate.Write(body); err != nil { + _ = tempTemplate.Close() + _ = os.Remove(tempTemplatePath) + 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 +} + +func setAnyString(m map[string]any, key string, value string) { + if strings.TrimSpace(value) != "" { + m[key] = strings.TrimSpace(value) + } +} + func (s *ConfigPreviewService) mediaRepoRoot() string { if s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" { return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath)) diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index cd74117..89b2340 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "os" "path/filepath" "strings" @@ -113,6 +114,143 @@ 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) { + 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":[]} +}`) + 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 { + 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{MediaRepoPath: root}, repo) + editor := ConfigProfileEditor{ + Name: "line_a", + ObjectStorageRef: "minio_main", + TokenServiceRef: "token_main", + AlarmServiceRef: "alarm_main", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam1", + Template: "std_workshop_face_recognition_shoe_alarm", + RTSPURL: "rtsp://10.0.0.1/live", + }}, + } + + result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{ + Template: "std_workshop_face_recognition_shoe_alarm", + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err != nil { + t.Fatalf("RenderProfileEditor: %v", err) + } + + var doc map[string]any + 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]) + } + } +} + +func saveIntegrationServiceForPreviewTest(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) { + t.Helper() + if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil { + t.Fatalf("SaveIntegrationService(%s): %v", name, err) + } +} + func sourceNames(items []ConfigSource) []string { out := make([]string, 0, len(items)) for _, item := range items { diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go index f5a9db1..90d1131 100644 --- a/internal/service/profile_editor.go +++ b/internal/service/profile_editor.go @@ -10,15 +10,18 @@ import ( ) 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"` - 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"` + 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"` } type ConfigProfileQueueEditor struct { @@ -87,12 +90,15 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit }) } return &ConfigProfileEditor{ - Name: firstString(raw["name"], name), - Path: path, - BusinessName: stringValue(raw["business_name"]), - Description: stringValue(raw["description"]), - DeviceCode: deviceCode, - SiteName: siteName, + 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"]), Queue: ConfigProfileQueueEditor{ Size: valueString(queueMap["size"]), Strategy: stringValue(queueMap["strategy"]), @@ -175,6 +181,9 @@ 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 != "" { diff --git a/internal/web/ui.go b/internal/web/ui.go index b927b8a..7569e14 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -74,6 +74,7 @@ type PageData struct { AssetProfiles []service.ConfigProfileAsset AssetProfile *service.ConfigProfileAsset AssetProfileEditor *service.ConfigProfileEditor + AssetIntegrations []service.ConfigIntegrationServiceAsset AssetOverlays []service.ConfigOverlayAsset AssetOverlay *service.ConfigOverlayAsset AssetInstanceCount int @@ -274,7 +275,7 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic "auditActionLabel": func(v string) string { switch strings.TrimSpace(v) { case "config_apply": - return "下发业务配置" + return "下发场景配置" case "reload": return "重载配置" case "rollback": @@ -433,7 +434,12 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/dashboard", u.pageDashboard) r.Get("/devices", u.pageDevices) r.Get("/devices/{id}/control", u.pageDeviceControl) + r.Get("/plans", u.pagePlans) + r.Get("/plans/{name}", u.pagePlan) + r.Post("/plans/{name}", u.actionPlanSave) + r.Get("/plans/{name}/export", u.pagePlanExport) r.Get("/assets", u.pageAssets) + r.Get("/assets/video-sources", u.pageAssetVideoSources) r.Get("/assets/templates", u.pageAssetTemplates) r.Post("/assets/templates/create", u.actionAssetTemplateCreate) r.Get("/assets/templates/{name}", u.pageAssetTemplate) @@ -442,10 +448,11 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph) r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave) r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport) - r.Get("/assets/profiles", u.pageAssetProfiles) - r.Get("/assets/profiles/{name}", u.pageAssetProfile) - r.Post("/assets/profiles/{name}", u.actionAssetProfileSave) - r.Get("/assets/profiles/{name}/export", u.pageAssetProfileExport) + r.Get("/assets/profiles", u.redirectAssetProfilesToPlans) + r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan) + r.Post("/assets/profiles/{name}", u.actionPlanSave) + r.Get("/assets/profiles/{name}/export", u.pagePlanExport) + r.Get("/assets/integrations", u.pageAssetIntegrations) r.Get("/assets/overlays", u.pageAssetOverlays) r.Get("/assets/overlays/{name}", u.pageAssetOverlay) r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) @@ -718,12 +725,12 @@ func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { req.Profile = data.SelectedProfile } if req.Profile == "" { - data.Error = "请先选择业务配置" + data.Error = "请先选择场景配置" u.render(w, r, "device_batch_config", data) return } if data.SelectedTemplate == "" { - data.Error = "所选业务配置缺少可用模板,无法生成下发内容" + data.Error = "所选场景配置缺少可用模板,无法生成下发内容" u.render(w, r, "device_batch_config", data) return } @@ -1303,7 +1310,7 @@ func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } if u.preview == nil { - data.Error = "配置资产服务未初始化" + data.Error = "基础配置服务未初始化" u.render(w, r, "asset_templates", data) return } @@ -1491,8 +1498,9 @@ func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name")) } -func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) { - data := u.assetPageData("profiles") +func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("") + data.Title = "场景配置" selected := strings.TrimSpace(r.URL.Query().Get("name")) if selected == "" && len(data.AssetProfiles) > 0 { selected = data.AssetProfiles[0].Name @@ -1509,25 +1517,37 @@ func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) { data.Error = err.Error() } } - u.render(w, r, "asset_profiles", data) + u.render(w, r, "plans", data) } -func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) { +func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) { + u.pagePlans(w, r) +} + +func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data, err := u.profileEditorPageData(name) if err != nil { http.NotFound(w, r) return } - data.Title = "识别配置" - u.render(w, r, "asset_profiles", data) + data.Title = "场景配置" + u.render(w, r, "plans", data) } -func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) { +func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) { + u.pagePlan(w, r) +} + +func (u *UI) pagePlanExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name")) } -func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { +func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) { + u.pagePlanExport(w, r) +} + +func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") editor, data, err := u.profileEditorActionData(r, name) if err != nil { @@ -1536,15 +1556,45 @@ func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { } if err := u.preview.SaveProfileEditor(editor); err != nil { data.Error = err.Error() - u.render(w, r, "asset_profiles", data) + data.Title = "场景配置" + u.render(w, r, "plans", data) return } if editor.Name != name { - data.Message = "业务配置已保存,名称已更新" + data.Message = "场景配置已保存,名称已更新" } else { - data.Message = "业务配置已保存" + data.Message = "场景配置已保存" } - u.render(w, r, "asset_profiles", data) + data.Title = "场景配置" + u.render(w, r, "plans", data) +} + +func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { + u.actionPlanSave(w, r) +} + +func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request) { + target := "/ui/plans" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) +} + +func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/plans/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) +} + +func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("video-sources") + data.Title = "基础配置" + u.render(w, r, "assets", data) +} + +func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("integrations") + data.Title = "基础配置" + u.render(w, r, "assets", data) } func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { @@ -1583,11 +1633,11 @@ func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) { func (u *UI) assetPageData(tab string) PageData { data := PageData{ - Title: "识别配置", + Title: "基础配置", AssetTab: tab, } if u.preview == nil { - data.Error = "配置资产服务未初始化" + data.Error = "基础配置服务未初始化" return data } sources, err := u.preview.ListSources() @@ -1613,6 +1663,11 @@ func (u *UI) assetPageData(tab string) PageData { } else if data.Error == "" { data.Error = listErr.Error() } + if items, listErr := u.preview.ListIntegrationServices(); listErr == nil { + data.AssetIntegrations = items + } else if data.Error == "" { + data.Error = listErr.Error() + } return data } @@ -1642,10 +1697,13 @@ 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")), + 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")), Queue: service.ConfigProfileQueueEditor{ Size: strings.TrimSpace(r.FormValue("queue_size")), Strategy: strings.TrimSpace(r.FormValue("queue_strategy")), @@ -2317,7 +2375,7 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData { data := u.deviceOverviewPageData(r, selectedIDs, "") sources, err := u.preview.ListSources() - data.Title = "下发业务配置" + data.Title = "下发场景配置" data.ConfigSources = sources data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs) profiles, profileErr := u.preview.ListProfileAssets() @@ -2457,7 +2515,7 @@ func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action s label := row.Device.DisplayName() switch action { case "reload": - summary := "未取到当前业务配置" + summary := "未取到当前场景配置" if row.ConfigStatus != nil { meta := row.ConfigStatus.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { @@ -2473,7 +2531,7 @@ func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action s } lines = append(lines, label+" -> "+summary) case "rollback": - summary := "未取到可回滚业务配置" + summary := "未取到可回滚场景配置" if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil { meta := row.ConfigStatus.PreviousConfig.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { diff --git a/internal/web/ui/templates/asset_profiles.html b/internal/web/ui/templates/asset_profiles.html index 84f7992..15a8bae 100644 --- a/internal/web/ui/templates/asset_profiles.html +++ b/internal/web/ui/templates/asset_profiles.html @@ -1,16 +1,15 @@ -{{define "asset_profiles"}} -{{template "asset_tabs" .}} +{{define "plans"}}
-

{{icon "profile"}}业务配置列表

+

{{icon "profile"}}场景配置列表

- + @@ -19,13 +18,13 @@ {{range .AssetProfiles}} - + {{else}} - + {{end}}
业务配置场景配置 描述 视频通道 队列
{{.Name}}{{.Name}} {{if .Description}}{{.Description}}{{else}}-{{end}} {{len .Instances}} {{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{else}}-{{end}}
还没有业务配置
还没有场景配置
@@ -33,23 +32,23 @@
{{if .AssetProfileEditor}} -
+
-

{{icon "profile"}}业务配置

+

{{icon "profile"}}场景配置

- + @@ -58,6 +57,43 @@
+
+
+
+

{{icon "service"}}第三方服务

+
+
+
+ + + +
+
+
@@ -134,5 +170,4 @@ {{end}} {{if .Error}}
{{.Error}}
{{end}} -{{template "asset_tabs_end" .}} {{end}} diff --git a/internal/web/ui/templates/assets.html b/internal/web/ui/templates/assets.html index 9f1da93..458ef1e 100644 --- a/internal/web/ui/templates/assets.html +++ b/internal/web/ui/templates/assets.html @@ -5,13 +5,16 @@ 总览 +
@@ -28,26 +31,85 @@ {{define "assets"}} {{template "asset_tabs" .}} +{{if eq .AssetTab "integrations"}} +
+
+
+

{{icon "service"}}第三方服务列表

+
+
+
+ + + + + + + + + + + + + {{range .AssetIntegrations}} + + + + + + + + + {{else}} + + {{end}} + +
服务名称服务类型描述地址摘要状态引用数量
{{.Name}}{{.TypeLabel}}{{if .Description}}{{.Description}}{{else}}-{{end}}{{if .AddressSummary}}{{.AddressSummary}}{{else}}-{{end}}{{if .Enabled}}启用{{else}}停用{{end}}{{.RefCount}}
还没有第三方服务
+
+
+{{else if eq .AssetTab "video-sources"}} +
+
+
+

{{icon "device"}}视频源

+
+
+
+ {{range .AssetProfiles}} + {{range .Instances}} +
+ {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}} + {{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}} +
+ {{end}} + {{else}} +
+
还没有视频源
+
+ {{end}} +
+
+{{else}}
-
{{icon "template"}}模板
+
{{icon "template"}}识别模板
{{len .AssetTemplates}}
{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}未定位到配置仓库{{end}}
-
{{icon "profile"}}业务配置
-
{{len .AssetProfiles}}
-
业务场景与通道参数
+
{{icon "device"}}视频源
+
{{.AssetInstanceCount}}
+
当前从运行方案中解析
-
{{icon "overlay"}}叠加项
+
{{icon "overlay"}}调试参数
{{len .AssetOverlays}}
调试与敏感度变化
-
{{icon "release"}}视频通道
-
{{.AssetInstanceCount}}
-
业务配置中定义的通道数
+
{{icon "service"}}第三方服务
+
{{len .AssetIntegrations}}
+
告警、对象存储和认证服务
@@ -55,7 +117,7 @@
-

{{icon "template"}}模板

+

{{icon "template"}}识别模板

@@ -75,18 +137,20 @@
-

{{icon "profile"}}业务配置

+

{{icon "device"}}视频源

{{range .AssetProfiles}} - - {{.Name}} - {{len .Instances}} 路通道 - + {{range .Instances}} +
+ {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}} + {{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}} +
+ {{end}} {{else}}
-
还没有业务配置
+
还没有视频源
{{end}}
@@ -95,7 +159,27 @@
-

{{icon "overlay"}}叠加项

+

{{icon "service"}}第三方服务

+
+
+
+ {{range .AssetIntegrations}} + + {{.Name}} + {{if .AddressSummary}}{{.AddressSummary}}{{else}}{{.TypeLabel}}{{end}} + + {{else}} +
+
还没有第三方服务
+
+ {{end}} +
+
+ +
+
+
+

{{icon "overlay"}}调试参数

@@ -106,26 +190,14 @@ {{else}}
-
还没有叠加项
+
还没有调试参数
{{end}}
-
-
-
-

{{icon "config"}}资产说明

-
-
-
-
模板定义处理链与共享服务接入
-
业务配置定义站点、视频源、输出流与通道参数
-
叠加项定义调试、敏感度和运行模式差异
-
原则不回到手工维护多份完整 JSON
-
-
+{{end}} {{if .Error}}
{{.Error}}
{{end}} {{template "asset_tabs_end" .}} diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index 4c936eb..f132c7c 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -289,7 +289,7 @@ func TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择"} { + for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发场景配置", "清空选择"} { if strings.Contains(body, forbidden) { t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body) } @@ -308,7 +308,7 @@ func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发业务配置", "清空选择", "将重载当前业务配置", "将回滚到上一版业务配置"} { + for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载配置", "回滚配置", "下发场景配置", "清空选择", "将重载当前场景配置", "将回滚到上一版场景配置"} { if !strings.Contains(body, want) { t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body) } @@ -338,12 +338,12 @@ func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) { } body := rr.Body.String() for _, want := range []string{ - "下发业务配置", - "业务配置", + "下发场景配置", + "场景配置", "已选设备", "入口识别节点", "辅助节点", - "业务配置摘要", + "场景配置摘要", "local_3588_test", "A厂区视觉识别", "std_workshop_face_recognition_shoe_alarm", @@ -490,7 +490,17 @@ func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) { func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) { ui := newTestUI(t) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createProfileEditorMediaRepo(t)}) + root := createProfileEditorMediaRepo(t) + 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()) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) + mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test", nil) req = withChiURLParam(req, "name", "local_3588_test") rr := httptest.NewRecorder() @@ -502,10 +512,14 @@ func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) { } body := rr.Body.String() for _, want := range []string{ - "业务配置", - "业务配置名称", + "场景配置", + "场景名称", "业务名称", "站点名", + "第三方服务", + "对象存储", + "认证服务", + "告警服务", "视频通道", "cam1", "cam2", @@ -535,13 +549,25 @@ func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) { func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) { root := createProfileEditorMediaRepo(t) ui := newTestUI(t) - ui.preview = service.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()) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) + mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) form := url.Values{} form.Set("profile_name", "local_3588_test") form.Set("business_name", "B厂区视觉识别") form.Set("description", "updated profile") form.Set("site_name", "B厂区") + form.Set("object_storage_ref", "minio_main") + form.Set("token_service_ref", "token_main") + form.Set("alarm_service_ref", "alarm_main") form.Set("instances[0].name", "cam1") form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm") form.Set("instances[0].display_name", "西门入口") @@ -573,21 +599,27 @@ func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - if !strings.Contains(body, "业务配置已保存") { + if !strings.Contains(body, "场景配置已保存") { t.Fatalf("expected save success message, got:\n%s", body) } - raw, err := os.ReadFile(filepath.Join(root, "configs", "profiles", "local_3588_test.json")) + saved, err := repo.GetProfile("local_3588_test") if err != nil { - t.Fatalf("read saved profile: %v", err) + t.Fatalf("GetProfile: %v", err) + } + if saved == nil { + t.Fatal("expected saved profile in repo") } var doc map[string]any - if err := json.Unmarshal(raw, &doc); err != nil { + if err := json.Unmarshal([]byte(saved.BodyJSON), &doc); err != nil { t.Fatalf("unmarshal saved profile: %v", err) } if doc["description"] != "updated profile" { t.Fatalf("unexpected description: %#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 saved profile, got %#v", doc) + } queue, _ := doc["queue"].(map[string]any) if queue["size"] != float64(9) { t.Fatalf("expected queue size 9, got %#v", queue) @@ -772,6 +804,13 @@ func writeTestFile(t *testing.T, path string, body string) { } } +func mustSaveIntegrationService(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) { + t.Helper() + if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil { + t.Fatalf("SaveIntegrationService(%s): %v", name, err) + } +} + func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) { ui := newTestUI(t) ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) @@ -863,7 +902,7 @@ func TestUI_DevicePageIncludesFilterAndControlEntry(t *testing.T) { t.Fatalf("expected device page HTML to contain %q", want) } } - for _, forbidden := range []string{"查看配置资产", "查看操作审计"} { + for _, forbidden := range []string{"查看基础配置", "查看操作审计"} { if strings.Contains(body, forbidden) { t.Fatalf("device overview should not contain redundant link %q", forbidden) } @@ -1247,8 +1286,8 @@ func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) { for _, want := range []string{ "配置预览", "模板", - "业务配置", - "配置叠加项", + "场景配置", + "调试参数", "config_id", "config_version", "生成预览", @@ -1542,7 +1581,7 @@ func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) { "config_id", "config_version", "SHA256", - "配置叠加项", + "调试参数", "face_debug", } { if !strings.Contains(body, want) { @@ -1750,11 +1789,13 @@ func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) { for _, want := range []string{ "总览", "设备", - "识别配置", + "场景配置", + "基础配置", "任务", "诊断", `href="/ui/dashboard"`, `href="/ui/devices"`, + `href="/ui/plans"`, `href="/ui/assets"`, `href="/ui/tasks"`, `href="/ui/diagnostics"`, @@ -1786,7 +1827,7 @@ func TestUI_DeviceConfigPageShowsDeviceSelector(t *testing.T) { t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body) } } - for _, forbidden := range []string{"模板", "业务配置", "叠加项", "配置资产"} { + for _, forbidden := range []string{"识别模板", "调试参数"} { if strings.Contains(body, forbidden) { t.Fatalf("expected config selector page to avoid asset-library copy %q", forbidden) } @@ -1939,18 +1980,56 @@ func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) { writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","description":"template","params":{"minio_endpoint":"http://10.0.0.49:9000"},"template":{"nodes":[{},{}],"edges":[[]]}}`) writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"display_name":"东门入口"}}]}`) writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) - ui.preview = service.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()) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil) rr := httptest.NewRecorder() ui.pageAssets(rr, req) body := rr.Body.String() - for _, want := range []string{"识别配置", "总览", "模板", "业务配置", "叠加项", "local_3588_test", "std_workshop_face_recognition_shoe_alarm", "face_debug"} { + for _, want := range []string{"基础配置", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} { if !strings.Contains(body, want) { t.Fatalf("expected assets HTML to contain %q", want) } } + if !strings.Contains(body, "minio_main") { + t.Fatalf("expected assets overview to contain integration service, got:\n%s", body) + } +} + +func TestUI_AssetIntegrationsPageShowsStructuredServices(t *testing.T) { + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","object_storage_ref":"minio_main","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"rtsp_url":"rtsp://10.0.0.1/live"}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + 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()) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + + req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations", nil) + rr := httptest.NewRecorder() + ui.pageAssetIntegrations(rr, req) + + body := rr.Body.String() + for _, want := range []string{"第三方服务列表", "minio_main", "token_main", "对象存储", "认证服务", "http://10.0.0.49:9000 / myminio", "1"} { + if !strings.Contains(body, want) { + t.Fatalf("expected integrations page to contain %q, got:\n%s", want, body) + } + } } func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) { @@ -1992,7 +2071,7 @@ func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) { ui.pageAssetProfile(rr, req) body := rr.Body.String() - for _, want := range []string{"业务配置", "local_3588_test", "业务名称", "A厂区视觉识别", "站点名", "A厂区", "东门入口", "rtsp://10.0.0.1/live", "高级设置", "queue_debug"} { + for _, want := range []string{"场景配置", "local_3588_test", "业务名称", "A厂区视觉识别", "站点名", "A厂区", "东门入口", "rtsp://10.0.0.1/live", "高级设置", "queue_debug"} { if !strings.Contains(body, want) { t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body) } @@ -2019,7 +2098,7 @@ func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) { ui.pageAssetTemplates(rr, req) body := rr.Body.String() - for _, want := range []string{"模板列表", "std_workshop_face_recognition_shoe_alarm", "helmet", "模板详情", "helmet template"} { + for _, want := range []string{"识别模板列表", "std_workshop_face_recognition_shoe_alarm", "helmet", "模板详情", "helmet template"} { if !strings.Contains(body, want) { t.Fatalf("expected template assets page to contain %q, got:\n%s", want, body) } @@ -2041,7 +2120,7 @@ func TestUI_OverlayAssetsPageShowsListAndSelectedDetail(t *testing.T) { ui.pageAssetOverlays(rr, req) body := rr.Body.String() - for _, want := range []string{"配置叠加项列表", "face_debug", "night_relaxed", "relaxed", "cam2"} { + for _, want := range []string{"调试参数列表", "face_debug", "night_relaxed", "relaxed", "cam2"} { if !strings.Contains(body, want) { t.Fatalf("expected overlay assets page to contain %q, got:\n%s", want, body) } @@ -2075,7 +2154,7 @@ func TestUI_ProfileAssetsPageShowsListAndSelectedEditor(t *testing.T) { ui.pageAssetProfiles(rr, req) body := rr.Body.String() - for _, want := range []string{"业务配置列表", "local_3588_test", "night_shift", "业务配置", "夜班巡检", "night profile", "西门", "rtsp://10.0.0.9/live"} { + for _, want := range []string{"场景配置列表", "local_3588_test", "night_shift", "场景配置", "夜班巡检", "night profile", "西门", "rtsp://10.0.0.9/live"} { if !strings.Contains(body, want) { t.Fatalf("expected profile assets page to contain %q, got:\n%s", want, body) } @@ -2183,7 +2262,7 @@ func TestUI_SidebarMatchesApprovedIoTArchitecture(t *testing.T) { ui := newTestUI(t) html := renderPage(t, ui, "/ui/devices") - for _, label := range []string{"总览", "设备", "识别配置", "任务", "诊断"} { + for _, label := range []string{"总览", "设备", "场景配置", "基础配置", "任务", "诊断"} { if !strings.Contains(html, label) { t.Fatalf("expected sidebar label %q in html: %s", label, html) } @@ -3006,7 +3085,7 @@ func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) { ui.auditRepo = auditRepo html := renderPage(t, ui, "/ui/audit") - for _, want := range []string{"task-99", "gate_a", "下发业务配置", "成功", "edge-01"} { + for _, want := range []string{"task-99", "gate_a", "下发场景配置", "成功", "edge-01"} { if !strings.Contains(html, want) { t.Fatalf("expected audit page to contain %q, got: %s", want, html) }