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