From 5620aad10b00abf448c0b9415f055d5c17ff350b Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Wed, 29 Apr 2026 10:42:24 +0800 Subject: [PATCH 01/10] Add third-party service storage support --- internal/service/config_assets.go | 81 +++++++++++++++++++ internal/service/config_assets_test.go | 106 +++++++++++++++++++++++++ internal/storage/assets_repo.go | 80 +++++++++++++++++++ internal/storage/assets_repo_test.go | 41 ++++++++++ internal/storage/migrate.go | 10 +++ internal/storage/sqlite_test.go | 1 + 6 files changed, 319 insertions(+) diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index d7045eb..2fcaa34 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -68,6 +68,15 @@ type ConfigOverlayAsset struct { Raw map[string]any `json:"raw"` } +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"` +} + func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) { items := make([]ConfigTemplateAsset, 0) seen := map[string]bool{} @@ -320,6 +329,42 @@ func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset }, nil } +func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) { + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListIntegrationServices() + if err != nil { + return nil, err + } + items := make([]ConfigIntegrationServiceAsset, 0, len(records)) + for _, record := range records { + item, err := integrationServiceAssetFromRecord(record) + if err != nil { + continue + } + items = append(items, *item) + } + return items, nil +} + +func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) { + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return nil, err + } + record, err := s.assets.GetIntegrationService(name) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + return integrationServiceAssetFromRecord(*record) +} + 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) @@ -468,6 +513,24 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly } } +func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) (*ConfigIntegrationServiceAsset, 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{} + } + return &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 +} + func (s *ConfigPreviewService) mediaAssetPath(kind string, name string) (string, bool) { root := s.mediaRepoRoot() if root == "" { @@ -519,6 +582,24 @@ func valueString(v any) string { } } +func boolValue(v any, fallback bool) bool { + switch value := v.(type) { + case bool: + return value + case float64: + return value != 0 + case int: + return value != 0 + case int64: + return value != 0 + case string: + value = strings.TrimSpace(strings.ToLower(value)) + return value == "1" || value == "true" || value == "yes" || value == "on" + default: + return fallback + } +} + func firstString(v any, fallback string) string { if got := stringValue(v); got != "" { return got diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index 1a828ea..c183942 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "os" "path/filepath" "strings" "testing" @@ -336,6 +337,111 @@ func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) { } } +func TestListIntegrationServices(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", + false, + `{"name":"minio_primary","type":"object_storage","provider":"minio","endpoint":"http://10.0.0.49:9000","enabled":false}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{}, repo) + items, err := svc.ListIntegrationServices() + if err != nil { + t.Fatalf("ListIntegrationServices: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 integration service, got %#v", items) + } + if items[0].Name != "minio_primary" || items[0].Type != "object_storage" || items[0].Enabled { + t.Fatalf("unexpected integration service summary: %#v", items[0]) + } + + item, err := svc.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if item == nil { + t.Fatal("expected integration service") + } + if item.Description != "primary object store" { + t.Fatalf("unexpected integration service description: %#v", item) + } + if item.Type != "object_storage" || item.Enabled { + t.Fatalf("unexpected integration service status: %#v", item) + } + if got := stringValue(item.Raw["type"]); got != "object_storage" { + t.Fatalf("expected raw type to come from body_json, got %#v", item.Raw) + } + 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" { + t.Fatalf("unexpected integration service endpoint: %#v", item.Raw) + } + if enabled, ok := item.Raw["enabled"].(bool); !ok || enabled { + t.Fatalf("expected raw enabled=false, got %#v", item.Raw) + } +} + +func TestGetIntegrationServiceNotFound(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())) + item, err := svc.GetIntegrationService("missing_service") + if !strings.Contains(err.Error(), "file does not exist") && !os.IsNotExist(err) { + t.Fatalf("expected not found error, got item=%#v err=%v", item, err) + } + if item != nil { + t.Fatalf("expected nil item for missing integration service, got %#v", item) + } +} + +func TestGetIntegrationServicePrefersRecordTypeOverRawType(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":"minio","provider":"minio","enabled":true}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{}, repo) + item, err := svc.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if item.Type != "object_storage" { + t.Fatalf("expected canonical type from record, got %#v", item) + } + if got := stringValue(item.Raw["type"]); got != "minio" { + t.Fatalf("expected raw type to preserve original body_json, got %#v", item.Raw) + } +} + func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index 677a5e7..cdfd607 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -18,6 +18,16 @@ type AssetRecord struct { UpdatedAt string } +type IntegrationServiceRecord struct { + Name string + ServiceType string + Description string + Enabled bool + BodyJSON string + CreatedAt string + UpdatedAt string +} + type AssetsRepo struct { db *sql.DB } @@ -52,6 +62,24 @@ func (r *AssetsRepo) SaveOverlay(name string, description string, bodyJSON strin }) } +func (r *AssetsRepo) SaveIntegrationService(name string, serviceType string, description string, enabled bool, bodyJSON string) error { + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` +INSERT INTO integration_services(name, type, description, enabled, body_json, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM integration_services WHERE name = ?), ?), ?) +ON CONFLICT(name) DO UPDATE SET + type=excluded.type, + description=excluded.description, + enabled=excluded.enabled, + body_json=excluded.body_json, + updated_at=excluded.updated_at +`, name, serviceType, description, enabled, bodyJSON, name, now, now) + return err +} + func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) { return r.listAssets("templates") } @@ -64,6 +92,31 @@ func (r *AssetsRepo) ListOverlays() ([]AssetRecord, error) { return r.listAssets("overlays") } +func (r *AssetsRepo) ListIntegrationServices() ([]IntegrationServiceRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` +SELECT name, type, description, enabled, body_json, created_at, updated_at +FROM integration_services +ORDER BY updated_at DESC, name ASC +`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []IntegrationServiceRecord + for rows.Next() { + var item IntegrationServiceRecord + if err := rows.Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &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) } @@ -76,10 +129,37 @@ func (r *AssetsRepo) GetOverlay(name string) (*AssetRecord, error) { return r.getAsset("overlays", name) } +func (r *AssetsRepo) GetIntegrationService(name string) (*IntegrationServiceRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + var item IntegrationServiceRecord + err := r.db.QueryRow(` +SELECT name, type, description, enabled, body_json, created_at, updated_at +FROM integration_services +WHERE name = ? +`, name).Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &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) } +func (r *AssetsRepo) DeleteIntegrationService(name string) error { + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`DELETE FROM integration_services 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 diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go index 1eb4db8..0c9d679 100644 --- a/internal/storage/assets_repo_test.go +++ b/internal/storage/assets_repo_test.go @@ -102,3 +102,44 @@ func TestAssetsRepoDeleteTemplateRemovesRecord(t *testing.T) { t.Fatalf("expected template deleted, got %#v", record) } } + +func TestAssetsRepoStoresIntegrationServiceDisabledState(t *testing.T) { + store := openTestStore(t) + defer store.Close() + + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + false, + `{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } + + records, err := repo.ListIntegrationServices() + if err != nil { + t.Fatalf("ListIntegrationServices: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one integration service, got %#v", records) + } + if records[0].ServiceType != "object_storage" || records[0].Enabled { + t.Fatalf("unexpected integration service record: %#v", records[0]) + } + + record, err := repo.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if record == nil { + t.Fatal("expected integration service record") + } + if record.ServiceType != "object_storage" || record.Enabled { + t.Fatalf("unexpected integration service fetch: %#v", record) + } + if !strings.Contains(record.BodyJSON, `"provider":"minio"`) { + t.Fatalf("expected body_json payload preserved, got %#v", record) + } +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index b42d66b..0b56b6a 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -29,6 +29,16 @@ CREATE TABLE IF NOT EXISTS overlays ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS integration_services ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 0, + 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 '', diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 126486b..8efea62 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -17,6 +17,7 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) { "templates", "profiles", "overlays", + "integration_services", "devices", "device_config_state", "tasks", From 12e2aac6f64de20ffa9a1c8e493c753f5d5e2437 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Wed, 29 Apr 2026 10:51:13 +0800 Subject: [PATCH 02/10] Add third-party service config flow --- internal/service/config_assets.go | 173 ++++++++++++++++-- internal/service/config_assets_test.go | 79 +++++++- internal/service/config_preview.go | 135 +++++++++++++- internal/service/config_preview_test.go | 138 ++++++++++++++ internal/service/profile_editor.go | 39 ++-- internal/web/ui.go | 116 +++++++++--- internal/web/ui/templates/asset_profiles.html | 57 ++++-- internal/web/ui/templates/assets.html | 138 ++++++++++---- internal/web/ui_test.go | 133 +++++++++++--- 9 files changed, 873 insertions(+), 135 deletions(-) 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) } From f41a924a2dfba6775a52fba8b29593cd4377d040 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Wed, 29 Apr 2026 11:25:48 +0800 Subject: [PATCH 03/10] Add video sources design spec --- .../specs/2026-04-29-video-sources-design.md | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-29-video-sources-design.md diff --git a/docs/superpowers/specs/2026-04-29-video-sources-design.md b/docs/superpowers/specs/2026-04-29-video-sources-design.md new file mode 100644 index 0000000..aa9f225 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-video-sources-design.md @@ -0,0 +1,323 @@ +# 视频源结构化设计 + +## 背景 + +当前后台已经将配置体系逐步拆分为两层: + +- `场景配置`:定义一个最终要运行的业务场景 +- `基础配置`:承载可被场景配置复用的公共配置 + +第三方服务已经明确要作为一类可复用的基础配置存在。视频输入端也具有相同特征: + +- 由客户现场提供 +- 生命周期通常长于单个场景配置 +- 多个场景可能复用同一路输入流 +- 修改地址或元信息时,不应该逐个修改场景配置 + +因此,视频输入不应继续散落在场景配置实例的 `rtsp_url` 等字段中,而应独立为一类基础配置。 + +## 核心定义 + +本设计中的**一个视频源 = 一路输入流**。 + +它不是一台物理摄像机设备,也不是一台 NVR,而是一条能够被场景配置直接引用的输入流。 + +例如: + +- 一台摄像机只有一路 RTSP,则对应一个视频源 +- 一个 NVR 下有 8 路通道,则应建 8 个视频源 + +这个定义与当前识别链路最匹配,也最利于场景配置引用和后续维护。 + +## 目标 + +本次设计要解决以下问题: + +1. 为视频源建立统一、结构化的数据模型 +2. 在 `基础配置` 中增加独立的 `视频源` 管理页面 +3. 让 `场景配置` 通过引用视频源,而不是直接填写 `rtsp_url` +4. 在不破坏现有配置链路的前提下,为后续逐步替换场景内联输入字段打基础 + +## 非目标 + +本次不包含以下内容: + +- 不做完整的摄像机资产台账 +- 不引入厂商、型号、采购信息、维护记录等固定资产字段 +- 不做视频源在线探测或取流连通性检测 +- 不做自动读取码流元信息 +- 不处理多通道设备实体模型 + +## 设计原则 + +1. 一个视频源只表示一路可引用输入流 +2. 识别相关字段优先,现场安装字段作为可选补充 +3. 表单默认保持简洁,非必要字段不阻塞创建 +4. 页面交互和第三方服务保持一致,降低认知成本 + +## 字段设计 + +推荐将视频源字段分为三组。 + +### 1. 基本信息 + +- `name`:视频源名称,唯一标识 +- `source_type`:视频源类型 +- `area`:区域 +- `description`:描述 + +说明: + +- `name` 用于被场景配置引用 +- `area` 用于表达现场语义,例如“东门入口”“1号产线”“仓库西侧” +- `description` 用于补充说明 + +### 2. 输入参数 + +- `url`:输入地址 +- `resolution`:分辨率,例如 `1920x1080` +- `fps`:帧率 +- `video_format`:视频格式,例如 `h264`、`h265`、`mjpeg` + +说明: + +- `url` 必填 +- `resolution`、`fps`、`video_format` 可选 + +### 3. 安装信息 + +- `focal_length`:焦距 +- `mount_height`:安装高度 +- `mount_angle`:安装角度 + +说明: + +- 三个字段均为可选 +- 它们主要用于表达现场安装条件,不应阻塞视频源创建 + +## 类型设计 + +第一版推荐支持以下视频源类型: + +- `rtsp` +- `rtmp` +- `file` +- `usb_camera` + +当前主路径仍然是 `rtsp`,但枚举应预先保留扩展空间。 + +页面文案可使用: + +- RTSP +- RTMP +- 文件流 +- USB 摄像头 + +## 数据结构 + +视频源建议使用与第三方服务类似的“顶层基础字段 + config 承载详细参数”的方式。 + +推荐结构: + +```json +{ + "name": "gate_cam_01", + "source_type": "rtsp", + "area": "东门入口", + "description": "东门主入口摄像头", + "config": { + "url": "rtsp://10.0.0.1/live", + "resolution": "1920x1080", + "fps": 25, + "video_format": "h264", + "focal_length": "4mm", + "mount_height": "3.2m", + "mount_angle": "15deg" + } +} +``` + +说明: + +- 顶层保持对象识别字段 +- `config` 用于承载输入参数和安装信息 +- 第一版不再继续向下拆分成多层嵌套,避免结构过重 + +## 与现有配置字段的关系 + +当前场景配置实例中已存在: + +- `display_name` +- `site_name` +- `rtsp_url` +- `channel_no` + +在新结构下: + +- `rtsp_url` 应逐步由视频源 `config.url` 替代 +- `display_name` 仍属于场景实例显示语义,不属于视频源本身 +- `site_name` 仍偏场景/站点上下文,不建议塞进视频源 +- `channel_no` 如果表达的是输入流标识,可保留在场景实例或后续再决定是否并入视频源 + +## 场景配置中的引用方式 + +场景配置不应长期继续直接持有 `rtsp_url`,而应改为引用视频源。 + +由于当前场景配置是多实例结构,推荐按实例增加引用字段: + +- `video_source_ref` + +示例: + +```json +{ + "instances": [ + { + "name": "cam1", + "template": "std_workshop_face_recognition_shoe_alarm", + "video_source_ref": "gate_cam_01", + "params": { + "display_name": "东门入口" + } + } + ] +} +``` + +这比把视频源引用放在整个场景顶层更合理,因为一个场景通常会包含多路输入。 + +## 兼容策略 + +第一阶段建议采用兼容式落地: + +1. 新建视频源资产 +2. 场景配置实例开始支持 `video_source_ref` +3. 若实例中存在 `video_source_ref`,则预览和下发时优先使用视频源中的 `url` +4. 若不存在 `video_source_ref`,仍兼容现有 `rtsp_url` + +这样可以逐步迁移,而不需要一次性改完所有历史配置。 + +## UI 设计 + +入口: + +- `基础配置 -> 视频源` + +页面交互与第三方服务保持一致: + +- 顶部按钮:`新增视频源`、`编辑`、`删除` +- 列表区:视频源列表 +- 详情区:默认只读 +- 点击 `编辑` 后进入编辑态 +- 点击 `新增视频源` 后清空详情并聚焦名称输入框 + +## 列表页字段 + +推荐列表列: + +- 视频源名称 +- 类型 +- 区域 +- URL 摘要 +- 分辨率 +- 帧率 + +说明: + +- 列表优先展示能帮助快速识别和定位的视频信息 +- 焦距、安装高度、安装角度不放在列表里 + +## 详情页字段分组 + +### 基本信息 + +- 视频源名称 +- 类型 +- 区域 +- 描述 + +### 输入参数 + +- URL +- 分辨率 +- 帧率 +- 视频格式 + +### 安装信息 + +- 焦距 +- 安装高度 +- 安装角度 + +## 校验规则 + +### 必填 + +- `name` +- `source_type` +- `config.url` + +### 可选 + +- `area` +- `description` +- `config.resolution` +- `config.fps` +- `config.video_format` +- `config.focal_length` +- `config.mount_height` +- `config.mount_angle` + +### 基本格式建议 + +- `name` 必须唯一 +- `fps` 如填写,应可解析为数值 +- `resolution` 第一版可先作为字符串保存,不强制拆成宽高 + +## 删除约束 + +如果某个视频源已被场景配置实例引用,则不允许直接删除。 + +删除前应检查: + +- 是否存在场景实例的 `video_source_ref` 指向该视频源 + +如果存在引用,应提示: + +- 当前视频源已被某些场景配置使用,不能删除 + +## 存储建议 + +建议沿用现有 SQLite 基础配置仓储模式,新增一类 `video_sources` 存储对象。 + +每条记录至少包含: + +- `name` +- `source_type` +- `area` +- `description` +- `body_json` +- `created_at` +- `updated_at` + +## 第一阶段实施范围 + +第一阶段只实现以下内容: + +1. 视频源的数据结构与存储 +2. `基础配置 -> 视频源` 列表和详情维护页 +3. 场景配置实例支持 `video_source_ref` +4. 预览/下发时优先用视频源 URL 展开 +5. 删除前引用检查 + +## 结论 + +视频源应被定义为**一路可被场景配置引用的输入流**。 + +采用“基础识别字段 + 输入参数 + 可选安装信息”的结构最适合当前系统阶段: + +- 比单纯只存 URL 更完整 +- 比完整摄像机资产台账更轻 +- 既能服务识别配置,又能保留现场语义 + +第一版推荐按 `video_source_ref` 的方式逐步替换场景配置中的内联 `rtsp_url`,从而让视频源真正成为可复用、可维护的基础配置对象。 From a5205ac359f39e4c64fbae348917cf007a138552 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Wed, 29 Apr 2026 11:27:29 +0800 Subject: [PATCH 04/10] Refine video source resolution fields --- .../specs/2026-04-29-video-sources-design.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-04-29-video-sources-design.md b/docs/superpowers/specs/2026-04-29-video-sources-design.md index aa9f225..b080c69 100644 --- a/docs/superpowers/specs/2026-04-29-video-sources-design.md +++ b/docs/superpowers/specs/2026-04-29-video-sources-design.md @@ -75,14 +75,15 @@ ### 2. 输入参数 - `url`:输入地址 -- `resolution`:分辨率,例如 `1920x1080` +- `resolution`:标准分辨率等级,例如 `720p`、`1080p`、`1440p`、`4k` +- `frame_size`:像素尺寸,例如 `1280x720`、`1920x1080` - `fps`:帧率 - `video_format`:视频格式,例如 `h264`、`h265`、`mjpeg` 说明: - `url` 必填 -- `resolution`、`fps`、`video_format` 可选 +- `resolution`、`frame_size`、`fps`、`video_format` 可选 ### 3. 安装信息 @@ -127,7 +128,8 @@ "description": "东门主入口摄像头", "config": { "url": "rtsp://10.0.0.1/live", - "resolution": "1920x1080", + "resolution": "1080p", + "frame_size": "1920x1080", "fps": 25, "video_format": "h264", "focal_length": "4mm", @@ -224,7 +226,8 @@ 说明: -- 列表优先展示能帮助快速识别和定位的视频信息 +- 列表中的“分辨率”优先显示 `resolution` 这类标准等级 +- `frame_size` 作为详情中的技术补充字段,不默认放在列表里 - 焦距、安装高度、安装角度不放在列表里 ## 详情页字段分组 @@ -240,6 +243,7 @@ - URL - 分辨率 +- 像素尺寸 - 帧率 - 视频格式 @@ -262,6 +266,7 @@ - `area` - `description` - `config.resolution` +- `config.frame_size` - `config.fps` - `config.video_format` - `config.focal_length` @@ -272,7 +277,8 @@ - `name` 必须唯一 - `fps` 如填写,应可解析为数值 -- `resolution` 第一版可先作为字符串保存,不强制拆成宽高 +- `resolution` 使用标准分辨率等级表达,例如 `720p`、`1080p` +- `frame_size` 第一版可先作为字符串保存,不强制拆成宽高整数 ## 删除约束 From 33d5f6f8ab11388f76559e62f8f3d41ace1b1874 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Wed, 29 Apr 2026 11:56:39 +0800 Subject: [PATCH 05/10] Add template scene binding refactor spec --- ...026-04-29-template-scene-binding-design.md | 462 ++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-29-template-scene-binding-design.md diff --git a/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md b/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md new file mode 100644 index 0000000..13d98fd --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md @@ -0,0 +1,462 @@ +# Template / Scene Binding Refactor Design + +## Goal + +Refactor the configuration model across the admin backend and the OrangePi media service so that: + +- `识别模板` only defines a single-stream processing flow and its default technical parameters. +- `基础配置` owns reusable concrete instances such as `视频源` and `第三方服务`. +- `场景配置` becomes the single assembly layer that binds template slots to concrete instances and scene-level output parameters. +- The agent runtime config format stays stable in the first phase; the main change is in the modeling and render pipeline. + +This is a cross-repo refactor involving: + +- `C:/Users/Tellme/apps/3588AdminBackend` +- `C:/Users/Tellme/apps/OrangePi3588Media` + +## Problem Statement + +The current template JSON mixes three different concerns: + +1. Flow definition +2. Scene instance parameters +3. Concrete third-party service endpoints and credentials + +That creates several problems: + +- Templates are not reusable enough because they carry site-specific and service-specific values. +- Service endpoints and credentials are duplicated in templates instead of being centrally managed. +- Scene configuration is not the true assembly layer even though it is the closest object to actual deployment. +- The same conceptual binding is split between template params, profile params, and runtime substitution. + +The target model should separate these concerns cleanly. + +## Target Information Architecture + +### 1. 识别模板 + +Template is a reusable processing definition for **one video stream**. + +Template owns: + +- Node graph +- Edge graph +- Default algorithm parameters +- Default preprocess and publish technical parameters +- Declarations of required input / output / service slots + +Template does **not** own: + +- Concrete video source URLs +- Concrete third-party service instances +- Scene-specific publish paths or ports +- Site-specific display labels + +### 2. 基础配置 + +Base config owns reusable concrete instances. + +Initial scope: + +- `视频源` +- `第三方服务` + +These objects are real instances that can be shared by many scenes. + +### 3. 场景配置 + +Scene config is the only assembly layer. + +It owns: + +- Template selection +- Binding of template input slots to video-source instances +- Binding of template service slots to third-party service instances +- Binding of template output slots to scene-specific publish settings +- Scene-level labels and business parameters + +## Data Model Boundaries + +### Template-owned fields + +These remain inside template definitions: + +- Node types +- Edges +- Model paths +- Thresholds +- Inference rates +- Preprocess defaults +- Publish codec defaults +- Alarm rule structure +- Default sink behavior + +Examples from existing templates: + +- `infer_fps` +- `conf_thresh` +- `nms_thresh` +- `dst_w` +- `dst_h` +- `codec` +- `bitrate_kbps` +- `rules` +- `face_rules` + +### Scene-owned fields + +These move to scene config: + +- `publish_hls_path` +- `publish_rtsp_port` +- `publish_rtsp_path` +- `channel_no` +- `display_name` +- `site_name` +- any scene-specific output behavior or business label + +`device_code` should be treated as legacy and kept only for compatibility during migration. It should not be expanded further as a primary scene-model concept. + +### Base-config-owned fields + +These move into reusable instance objects: + +#### 视频源 + +- input URL +- source type +- resolution metadata +- frame size +- fps +- format +- installation metadata + +#### 第三方服务 + +- object storage endpoint / bucket / keys +- token service URL +- alarm service URL +- tenant or service-side connection metadata + +## Slot-Based Template Model + +The core change is to stop treating template placeholders as generic flat vars and instead treat them as **declared slots**. + +### Template slot categories + +Templates may declare: + +- `input` slots +- `service` slots +- `output` slots + +Examples: + +- `video_input_main` +- `object_storage_main` +- `token_service_main` +- `alarm_service_main` +- `stream_output_main` + +### Meaning of slots + +A slot expresses a requirement, not an instance. + +Examples: + +- `video_input_main` means this template requires one concrete video input. +- `object_storage_main` means this template requires one object storage service instance. +- `stream_output_main` means this template produces a stream and needs scene-level output binding. + +## Scene Binding Model + +Scene config binds template slots to concrete values. + +### Input binding + +Example: + +```json +{ + "input_bindings": { + "video_input_main": { + "video_source_ref": "gate_cam_01" + } + } +} +``` + +### Service binding + +Example: + +```json +{ + "service_bindings": { + "object_storage_main": { + "service_ref": "minio_prod_main" + }, + "token_service_main": { + "service_ref": "token_service_prod" + }, + "alarm_service_main": { + "service_ref": "alarm_center_prod" + } + } +} +``` + +### Output binding + +Example: + +```json +{ + "output_bindings": { + "stream_output_main": { + "publish_hls_path": "./web/hls/cam1/index.m3u8", + "publish_rtsp_port": 8555, + "publish_rtsp_path": "/live/cam1" + } + } +} +``` + +### Scene metadata + +Scene keeps business-facing labels: + +- `display_name` +- `site_name` +- `channel_no` + +These are scene-level values and should not live in templates. + +## Backward Compatibility Strategy + +Phase 1 must preserve the existing agent runtime config format. + +That means: + +- `render_config.py` may continue producing the current final flat config shape. +- Agent runtime does not need to understand slots yet. +- Binding logic is introduced in the config-render pipeline, not in the agent runtime. + +Compatibility rules: + +1. If new slot bindings exist, they take priority. +2. If old flat fields still exist, they remain supported during migration. +3. Existing templates and scene configs must continue rendering during the transition. + +## Existing Field Migration Matrix + +### Input-related + +- `rtsp_url` + - Current location: scene/profile params + - Target location: video source instance referenced from scene input binding + - Compatibility: continue supporting direct inline `rtsp_url` + +### Output-related + +- `publish_hls_path` + - Current location: scene/profile params + - Target location: scene output binding +- `publish_rtsp_port` + - Current location: scene/profile params + - Target location: scene output binding +- `publish_rtsp_path` + - Current location: scene/profile params + - Target location: scene output binding + +### Scene label fields + +- `channel_no` + - Current location: scene/profile params and used by alarm external API + - Target location: scene metadata or output binding context +- `display_name` + - Current location: scene/profile params + - Target location: scene metadata +- `site_name` + - Current location: scene/profile params + - Target location: scene metadata +- `device_code` + - Current location: scene/profile params + - Target location: legacy compatibility only in phase 1 + +### Third-party service fields + +- `minio_endpoint` + - Current location: template params + - Target location: object storage service instance +- `minio_bucket` + - Current location: template params + - Target location: object storage service instance +- `minio_access_key` + - Current location: template params + - Target location: object storage service instance +- `minio_secret_key` + - Current location: template params + - Target location: object storage service instance +- `external_get_token_url` + - Current location: template params + - Target location: token service instance +- `external_put_message_url` + - Current location: template params + - Target location: alarm service instance +- `tenant_code` + - Current location: template params + - Target location: token/alarm service instance, with optional scene-level override if needed later + +## Cross-Repo Responsibilities + +### 3588AdminBackend + +Admin backend changes: + +- Extend template asset model to expose slot declarations. +- Extend scene config model to store slot bindings. +- Maintain `视频源` and `第三方服务` as reusable base config objects. +- Update scene-config UI so users bind template slots to concrete instances. +- Update preview pipeline inputs to pass structured bindings to the renderer. + +### OrangePi3588Media + +Media service changes: + +- Upgrade template JSON model to support explicit slot declarations. +- Update `render_config.py` to resolve slot bindings into the current runtime config format. +- Keep final generated runtime config compatible with the current agent in phase 1. +- Support fallback rendering for older templates and profiles during migration. + +## Rendering Strategy + +Rendering should become a two-stage process. + +### Stage 1: Resolve scene bindings + +Resolve: + +- video source refs +- third-party service refs +- scene output parameters + +into a structured bound scene model. + +### Stage 2: Expand into final runtime config + +Expand the bound scene model into the current flat runtime config fields expected by the agent. + +Examples: + +- resolved video source URL becomes runtime `rtsp_url` +- resolved object storage instance becomes runtime `minio_*` +- resolved alarm/token services become runtime `external_*` and `tenant_code` + +This lets us modernize the model without forcing an immediate runtime-schema rewrite. + +## UI Implications + +### Template page + +Template page should gradually shift from editing concrete values to editing: + +- graph structure +- default technical parameters +- declared slots + +It should no longer encourage editing concrete service endpoints or stream addresses inside templates. + +### Scene page + +Scene page becomes the main assembly workspace. + +It should expose: + +- input bindings +- service bindings +- output bindings +- scene labels and business fields + +### Base-config pages + +These remain the concrete instance maintenance surfaces: + +- video source list/detail +- third-party service list/detail + +## Phased Rollout + +### Phase 1 + +- Keep current runtime config shape. +- Add slot-aware scene bindings. +- Add renderer logic that expands new bindings into old flat values. +- Preserve legacy flat template/profile fields. + +### Phase 2 + +- Move standard templates to explicit slot declarations. +- Stop storing real service endpoints in new template definitions. +- Prefer video-source refs over inline stream URLs. + +### Phase 3 + +- Remove or deprecate legacy flat substitutions from template authoring. +- Reduce compatibility code once all standard and active custom configs are migrated. + +## Risks + +### 1. Dual-model complexity during migration + +Supporting both legacy flat fields and new bindings will increase renderer complexity temporarily. + +Mitigation: + +- Keep priority order explicit. +- Add fixture-based render tests for old and new models. + +### 2. Template authoring confusion + +Users may still expect templates to own instance values. + +Mitigation: + +- Shift template UI away from concrete service editing. +- Make scene config the clear assembly surface. + +### 3. Cross-repo drift + +If backend and media-repo schema changes are not kept aligned, preview and apply flows will break. + +Mitigation: + +- Treat slot/binding schema as a shared contract. +- Land cross-repo fixture tests around the render boundary. + +## Testing Strategy + +### Admin backend + +- Unit tests for slot-aware scene models +- Unit tests for binding persistence +- UI tests for scene binding forms +- Preview tests for structured binding expansion + +### Media repo + +- Fixture tests for template slot declarations +- Fixture tests for legacy-template rendering +- Fixture tests for new scene-binding rendering +- Golden tests for final runtime config output + +## Recommendation + +Use the ideal layered model now, but keep runtime compatibility in phase 1: + +- Template defines requirements and defaults. +- Base config provides reusable concrete instances. +- Scene config performs all actual binding. +- Render pipeline expands the new structure into the current agent runtime config. + +This delivers the clean architecture we want without forcing a dangerous all-at-once runtime rewrite. From 328705ebc64c746030a06165deaea82e04782388 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Wed, 29 Apr 2026 18:59:57 +0800 Subject: [PATCH 06/10] Refactor config management flow and admin IA --- cmd/managerd/main.go | 6 + internal/config/config.go | 2 +- internal/service/config_assets.go | 479 ++++++--- internal/service/config_assets_test.go | 394 ++++++-- internal/service/config_preview.go | 562 +++++------ internal/service/config_preview_test.go | 362 +++++-- internal/service/config_runtime_render.go | 415 ++++++++ internal/service/profile_editor.go | 324 ++++-- internal/service/standard_templates.go | 75 ++ internal/service/template_slots.go | 67 ++ internal/service/template_slots_test.go | 34 + internal/storage/assets_repo.go | 92 +- internal/storage/assets_repo_test.go | 41 + internal/storage/migrate.go | 97 +- internal/storage/sqlite_test.go | 57 ++ internal/web/graph_node_types.go | 4 +- internal/web/ui.go | 581 ++++++++++- internal/web/ui/assets/graph_editor.js | 4 +- internal/web/ui/assets/style.css | 25 +- internal/web/ui/templates/api.html | 2 +- internal/web/ui/templates/asset_overlay.html | 4 +- internal/web/ui/templates/asset_overlays.html | 10 +- internal/web/ui/templates/asset_profile.html | 99 -- internal/web/ui/templates/asset_profiles.html | 178 ++-- internal/web/ui/templates/asset_template.html | 8 +- .../web/ui/templates/asset_templates.html | 31 +- internal/web/ui/templates/assets.html | 170 +++- internal/web/ui/templates/audit.html | 2 +- internal/web/ui/templates/config_preview.html | 6 +- internal/web/ui/templates/device.html | 54 +- .../web/ui/templates/device_batch_config.html | 18 +- internal/web/ui/templates/devices.html | 6 +- internal/web/ui/templates/diagnostics.html | 20 +- internal/web/ui/templates/layout.html | 83 +- internal/web/ui/templates/models.html | 32 +- internal/web/ui/templates/resources.html | 43 + internal/web/ui/templates/system.html | 2 +- internal/web/ui_test.go | 927 +++++++++++++++--- scripts/restart.bat | 30 + .../std_face_recognition_stream.json | 214 ++++ .../std_service_test_stream.json | 83 ++ .../std_workshoe_detection_stream.json | 302 ++++++ ..._workshop_face_recognition_shoe_alarm.json | 552 +++++++++++ 43 files changed, 5306 insertions(+), 1191 deletions(-) create mode 100644 internal/service/config_runtime_render.go create mode 100644 internal/service/standard_templates.go create mode 100644 internal/service/template_slots.go create mode 100644 internal/service/template_slots_test.go delete mode 100644 internal/web/ui/templates/asset_profile.html create mode 100644 internal/web/ui/templates/resources.html create mode 100644 scripts/restart.bat create mode 100644 templates/standard_templates/std_face_recognition_stream.json create mode 100644 templates/standard_templates/std_service_test_stream.json create mode 100644 templates/standard_templates/std_workshoe_detection_stream.json create mode 100644 templates/standard_templates/std_workshop_face_recognition_shoe_alarm.json diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index ed1c083..1dbec70 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 480e666..16b3394 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index e91863e..9a90c88 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -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 { diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index ed91e1d..ed5e76c 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -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) } } diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go index 1904cae..8242bf8 100644 --- a/internal/service/config_preview.go +++ b/internal/service/config_preview.go @@ -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"]) } diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index 89b2340..7cb96f3 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -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) } } diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go new file mode 100644 index 0000000..bee4e55 --- /dev/null +++ b/internal/service/config_runtime_render.go @@ -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 +} diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go index 90d1131..7976aab 100644 --- a/internal/service/profile_editor.go +++ b/internal/service/profile_editor.go @@ -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 +} diff --git a/internal/service/standard_templates.go b/internal/service/standard_templates.go new file mode 100644 index 0000000..8b62b31 --- /dev/null +++ b/internal/service/standard_templates.go @@ -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 +} diff --git a/internal/service/template_slots.go b/internal/service/template_slots.go new file mode 100644 index 0000000..241dcef --- /dev/null +++ b/internal/service/template_slots.go @@ -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 +} + diff --git a/internal/service/template_slots_test.go b/internal/service/template_slots_test.go new file mode 100644 index 0000000..e2c4b85 --- /dev/null +++ b/internal/service/template_slots_test.go @@ -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) + } +} + diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index cdfd607..1dc9954 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -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 = ? ` diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go index 0c9d679..e891ecb 100644 --- a/internal/storage/assets_repo_test.go +++ b/internal/storage/assets_repo_test.go @@ -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) + } +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index 0b56b6a..443722f 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -1,6 +1,9 @@ package storage -import "database/sql" +import ( + "database/sql" + "fmt" +) const schema001 = ` CREATE TABLE IF NOT EXISTS templates ( @@ -14,7 +17,7 @@ CREATE TABLE IF NOT EXISTS templates ( CREATE TABLE IF NOT EXISTS profiles ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, - template_name TEXT NOT NULL DEFAULT '', + primary_template_name TEXT NOT NULL DEFAULT '', business_name TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', body_json TEXT NOT NULL, @@ -39,6 +42,16 @@ CREATE TABLE IF NOT EXISTS integration_services ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS video_sources ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + source_type TEXT NOT NULL DEFAULT '', + area TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + body_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS devices ( device_id TEXT PRIMARY KEY, hostname TEXT NOT NULL DEFAULT '', @@ -93,6 +106,82 @@ CREATE TABLE IF NOT EXISTS audit_logs ( ` func migrate(db *sql.DB) error { - _, err := db.Exec(schema001) - return err + if _, err := db.Exec(schema001); err != nil { + return err + } + return migrateProfilePrimaryTemplateName(db) +} + +func migrateProfilePrimaryTemplateName(db *sql.DB) error { + hasPrimary, err := hasColumn(db, "profiles", "primary_template_name") + if err != nil { + return err + } + if hasPrimary { + return nil + } + hasLegacy, err := hasColumn(db, "profiles", "template_name") + if err != nil { + return err + } + if !hasLegacy { + return fmt.Errorf("profiles table is missing both primary_template_name and template_name") + } + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil { + return err + } + if _, err := tx.Exec(` +CREATE TABLE profiles ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + primary_template_name TEXT NOT NULL DEFAULT '', + business_name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + body_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +)`); err != nil { + return err + } + if _, err := tx.Exec(` +INSERT INTO profiles(id, name, primary_template_name, business_name, description, body_json, created_at, updated_at) +SELECT id, name, template_name, business_name, description, body_json, created_at, updated_at +FROM profiles_legacy_template_name +`); err != nil { + return err + } + if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil { + return err + } + return tx.Commit() +} + +func hasColumn(db *sql.DB, table string, column string) (bool, error) { + rows, err := db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dfltValue sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 8efea62..e0b3bf9 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -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) + } +} diff --git a/internal/web/graph_node_types.go b/internal/web/graph_node_types.go index 94fd14a..6cec1e7 100644 --- a/internal/web/graph_node_types.go +++ b/internal/web/graph_node_types.go @@ -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"), diff --git a/internal/web/ui.go b/internal/web/ui.go index 7569e14..6037400 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -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 { diff --git a/internal/web/ui/assets/graph_editor.js b/internal/web/ui/assets/graph_editor.js index cc3c9d6..e04a549 100644 --- a/internal/web/ui/assets/graph_editor.js +++ b/internal/web/ui/assets/graph_editor.js @@ -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" }, diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 2183c4f..413c702 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -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} diff --git a/internal/web/ui/templates/api.html b/internal/web/ui/templates/api.html index 920b72a..e18e4c6 100644 --- a/internal/web/ui/templates/api.html +++ b/internal/web/ui/templates/api.html @@ -1,6 +1,6 @@ {{define "api"}}
-
诊断 / 高级调试
+
系统管理 / 日志审计 / 高级调试

高级调试

diff --git a/internal/web/ui/templates/asset_overlay.html b/internal/web/ui/templates/asset_overlay.html index 7de4a85..8459a13 100644 --- a/internal/web/ui/templates/asset_overlay.html +++ b/internal/web/ui/templates/asset_overlay.html @@ -6,10 +6,10 @@

{{icon "overlay"}}{{.AssetOverlay.Name}}

- 返回叠加项列表 + 返回调试参数列表
-
叠加项{{.AssetOverlay.Name}}
+
调试参数{{.AssetOverlay.Name}}
目标数量{{.AssetOverlay.OverrideTargetNum}}
描述{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}
作用目标{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
diff --git a/internal/web/ui/templates/asset_overlays.html b/internal/web/ui/templates/asset_overlays.html index 686026c..9f9a6b9 100644 --- a/internal/web/ui/templates/asset_overlays.html +++ b/internal/web/ui/templates/asset_overlays.html @@ -3,14 +3,14 @@
-

{{icon "overlay"}}配置叠加项列表

+

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

- + @@ -23,7 +23,7 @@ {{else}} - + {{end}}
叠加项调试参数 描述 目标
{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
还没有叠加项
还没有调试参数
@@ -34,7 +34,7 @@
-

{{icon "overlay"}}叠加项详情

+

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

-
叠加项{{.AssetOverlay.Name}}
+
调试参数{{.AssetOverlay.Name}}
目标数量{{.AssetOverlay.OverrideTargetNum}}
描述{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}
作用目标{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
diff --git a/internal/web/ui/templates/asset_profile.html b/internal/web/ui/templates/asset_profile.html deleted file mode 100644 index a0decd0..0000000 --- a/internal/web/ui/templates/asset_profile.html +++ /dev/null @@ -1,99 +0,0 @@ -{{define "asset_profile"}} -{{template "asset_tabs" .}} -{{if .AssetProfileEditor}} - -
-
-
-

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

-
- 返回业务配置列表 -
-
- - - - - - -
-
- -
-
-
-

{{icon "device"}}视频通道

-
-
- {{len .AssetProfileEditor.Instances}} 路 - -
-
- - - - - - - - - - - - - {{range $i, $inst := .AssetProfileEditor.Instances}} - - - - - - - - - {{end}} - -
通道通道显示名RTSP 输入HLS 输出RTSP 输出
{{$inst.Name}}{{if $inst.Delete}}待删除{{else}}{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}{{end}}{{if $inst.Delete}}-{{else}}{{$inst.RTSPURL}}{{end}}{{if $inst.Delete}}-{{else}}{{$inst.PublishHLSPath}}{{end}}{{if $inst.Delete}}-{{else}}{{if $inst.PublishRTSPPort}}{{$inst.PublishRTSPPort}}{{end}}{{if $inst.PublishRTSPPath}} {{$inst.PublishRTSPPath}}{{end}}{{end}} -
- 编辑 - {{if $inst.Delete}} - - {{else}} - - {{end}} -
-
-
- - {{range $i, $inst := .AssetProfileEditor.Instances}} -
- {{icon "device"}}{{$inst.Name}} -
- - - - - - - - - - -
-
- {{end}} - -
-
- -
-
- - -
- {{icon "tech"}}原始 JSON -
{{json .AssetProfileEditor.Raw}}
-
-{{end}} -{{if .Error}}
{{.Error}}
{{end}} -{{template "asset_tabs_end" .}} -{{end}} diff --git a/internal/web/ui/templates/asset_profiles.html b/internal/web/ui/templates/asset_profiles.html index 15a8bae..c53e149 100644 --- a/internal/web/ui/templates/asset_profiles.html +++ b/internal/web/ui/templates/asset_profiles.html @@ -33,12 +33,15 @@ {{if .AssetProfileEditor}}
+

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

+ + {{icon "close"}}取消
- + @@ -57,43 +60,6 @@
-
-
-
-

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

-
-
-
- - - -
-
-
@@ -108,29 +74,65 @@ 通道 - 通道显示名 - RTSP 输入 - HLS 输出 - RTSP 输出 + 模板 + 输入绑定 + 服务绑定 + 输出绑定 {{range $i, $inst := .AssetProfileEditor.Instances}} - + {{$template := index $.AssetTemplateMap $inst.Template}} + {{$inst.Name}} - {{if $inst.Delete}}待删除{{else}}{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}{{end}} - {{if $inst.Delete}}-{{else}}{{$inst.RTSPURL}}{{end}} - {{if $inst.Delete}}-{{else}}{{$inst.PublishHLSPath}}{{end}} - {{if $inst.Delete}}-{{else}}{{if $inst.PublishRTSPPort}}{{$inst.PublishRTSPPort}}{{end}}{{if $inst.PublishRTSPPath}} {{$inst.PublishRTSPPath}}{{end}}{{end}} + {{if $inst.Delete}}待删除{{else}}{{$inst.Template}}{{end}} + + {{if $inst.Delete}}-{{else}} + {{if $template.Slots.Inputs}} + {{range $slot := $template.Slots.Inputs}} +
+ {{$slot.Description}} + {{if inputBindingRef $inst.InputBindings $slot.Name}}{{inputBindingRef $inst.InputBindings $slot.Name}}{{else}}-{{end}} +
+ {{end}} + {{else}}-{{end}} + {{end}} + + + {{if $inst.Delete}}-{{else}} + {{if $template.Slots.Services}} + {{range $slot := $template.Slots.Services}} +
+ {{$slot.Description}} + {{if serviceBindingRef $inst.ServiceBindings $slot.Name}}{{serviceBindingRef $inst.ServiceBindings $slot.Name}}{{else}}-{{end}} +
+ {{end}} + {{else}}-{{end}} + {{end}} + + + {{if $inst.Delete}}-{{else}} + {{if $template.Slots.Outputs}} + {{range $slot := $template.Slots.Outputs}} +
+ {{$slot.Description}} + + {{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}} + +
+ {{end}} + {{else}}-{{end}} + {{end}} +
- 编辑 - {{if $inst.Delete}} - - {{else}} - - {{end}} + +
@@ -139,29 +141,59 @@
- {{range $i, $inst := .AssetProfileEditor.Instances}} -
- {{icon "device"}}{{$inst.Name}} -
- - - - - - - - - - -
-
- {{end}} - + {{if .AssetProfileEditor.Instances}}
-
- +
+
+

{{icon "device"}}通道详情

+
当前通道的修改会在点击“保存场景配置”后统一生效。
+
+ {{range $i, $inst := .AssetProfileEditor.Instances}} + {{$template := index $.AssetTemplateMap $inst.Template}} +
+
+ + + + + {{range $slot := $template.Slots.Inputs}} + + {{end}} + {{range $slot := $template.Slots.Services}} + + {{end}} + {{range $slot := $template.Slots.Outputs}} + + + + + {{end}} +
+ 高级设置 JSON + +
+
+
+ {{end}}
+ {{end}}
diff --git a/internal/web/ui/templates/asset_template.html b/internal/web/ui/templates/asset_template.html index d2bea1c..5343d62 100644 --- a/internal/web/ui/templates/asset_template.html +++ b/internal/web/ui/templates/asset_template.html @@ -13,11 +13,9 @@
来源文件{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}
节点数{{.AssetTemplate.NodeCount}}
连线数{{.AssetTemplate.EdgeCount}}
-
MinIO{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}
-
Bucket{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}
-
取 token 接口{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}
-
告警上报接口{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}
-
租户编码{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}
+
输入槽位{{len .AssetTemplate.Slots.Inputs}}
+
服务槽位{{len .AssetTemplate.Slots.Services}}
+
输出槽位{{len .AssetTemplate.Slots.Outputs}}
描述{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}
路径{{.AssetTemplate.Path}}
diff --git a/internal/web/ui/templates/asset_templates.html b/internal/web/ui/templates/asset_templates.html index 4c36414..84b0817 100644 --- a/internal/web/ui/templates/asset_templates.html +++ b/internal/web/ui/templates/asset_templates.html @@ -3,7 +3,7 @@
-

{{icon "template"}}模板列表

+

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

标准模板应作为基线,复制后再做定制化修改。空白模板仅用于高级场景。
@@ -105,11 +105,9 @@
来源文件{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}
节点数{{.AssetTemplate.NodeCount}}
连线数{{.AssetTemplate.EdgeCount}}
-
MinIO{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}
-
Bucket{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}
-
取 token 接口{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}
-
告警上报接口{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}
-
租户编码{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}
+
输入槽位{{if .AssetTemplate.Slots.Inputs}}{{len .AssetTemplate.Slots.Inputs}}{{else}}0{{end}}
+
服务槽位{{if .AssetTemplate.Slots.Services}}{{len .AssetTemplate.Slots.Services}}{{else}}0{{end}}
+
输出槽位{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}
描述 {{if .AssetTemplate.ReadOnly}} @@ -131,6 +129,27 @@
+{{if or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs}} +
+
+
+

{{icon "config"}}模板槽位

+
+
+
+ {{range .AssetTemplate.Slots.Inputs}} +
输入{{.Name}} {{.Type}}
+ {{end}} + {{range .AssetTemplate.Slots.Services}} +
服务{{.Name}} {{.Type}}
+ {{end}} + {{range .AssetTemplate.Slots.Outputs}} +
输出{{.Name}} {{.Type}}
+ {{end}} +
+
+{{end}} + {{if .AssetTemplate.ReadOnly}}
标准模板保留在模板目录中,作为只读基线使用。需要调整流程时,请先复制为用户模板,再进入可视化编辑保存。
diff --git a/internal/web/ui/templates/assets.html b/internal/web/ui/templates/assets.html index 458ef1e..9211727 100644 --- a/internal/web/ui/templates/assets.html +++ b/internal/web/ui/templates/assets.html @@ -37,6 +37,15 @@

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

+
+ {{icon "apply"}}新增服务 + {{if .AssetIntegration.Name}} + {{icon "edit"}}编辑 +
+ +
+ {{end}} +
@@ -53,7 +62,7 @@ {{range .AssetIntegrations}} - + @@ -67,39 +76,156 @@
{{.Name}}{{.Name}} {{.TypeLabel}} {{if .Description}}{{.Description}}{{else}}-{{end}} {{if .AddressSummary}}{{.AddressSummary}}{{else}}-{{end}}
+{{if .AssetIntegration}} +
+
+
+
+

{{icon "service"}}{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}

+
+
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ + {{if .AssetIntegrationEditing}} +
+ +
+ {{end}} +
+
+{{end}} {{else if eq .AssetTab "video-sources"}}
-

{{icon "device"}}视频源

+

{{icon "device"}}视频源列表

+
+
+ {{icon "apply"}}新增视频源 + {{if .AssetVideoSource.Name}} + {{icon "edit"}}编辑 +
+ +
+ {{end}}
-
- {{range .AssetProfiles}} - {{range .Instances}} -
- {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}} - {{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}} +
+ + + + + + + + + + + + + + {{range .AssetVideoSources}} + + + + + + + + + + {{else}} + + {{end}} + +
视频源名称类型区域输入地址分辨率帧率引用数量
{{.Name}}{{.SourceTypeLabel}}{{if .Area}}{{.Area}}{{else}}-{{end}}{{if .Config.URL}}{{.Config.URL}}{{else}}-{{end}}{{if .Config.Resolution}}{{.Config.Resolution}}{{else}}-{{end}}{{if .Config.FPS}}{{.Config.FPS}}{{else}}-{{end}}{{.RefCount}}
还没有视频源
+
+
+{{if .AssetVideoSource}} +
+
+
+
+

{{icon "device"}}{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}

- {{end}} - {{else}} -
-
还没有视频源
+
+
+ + + + +
+
+ + + + + + + + +
+ {{if .AssetVideoSourceEditing}} +
+ + {{icon "close"}}取消
{{end}}
-
+
+{{end}} {{else}}
{{icon "template"}}识别模板
{{len .AssetTemplates}}
-
{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}未定位到配置仓库{{end}}
+
{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}标准模板与配置均存储在本地数据库{{end}}
{{icon "device"}}视频源
-
{{.AssetInstanceCount}}
-
当前从运行方案中解析
+
{{len .AssetVideoSources}}
+
可复用输入流配置
{{icon "overlay"}}调试参数
@@ -141,13 +267,11 @@
- {{range .AssetProfiles}} - {{range .Instances}} -
- {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}} - {{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}} -
- {{end}} + {{range .AssetVideoSources}} + + {{.Name}} + {{if .Area}}{{.Area}} / {{end}}{{if .Config.Resolution}}{{.Config.Resolution}}{{else}}未设置分辨率{{end}} + {{else}}
还没有视频源
diff --git a/internal/web/ui/templates/audit.html b/internal/web/ui/templates/audit.html index 2a9f895..769a981 100644 --- a/internal/web/ui/templates/audit.html +++ b/internal/web/ui/templates/audit.html @@ -2,7 +2,7 @@
-
诊断 / 审计记录
+
系统管理 / 日志审计 / 审计记录

{{icon "audit"}}审计记录

diff --git a/internal/web/ui/templates/config_preview.html b/internal/web/ui/templates/config_preview.html index e48dc2c..81a2dc9 100644 --- a/internal/web/ui/templates/config_preview.html +++ b/internal/web/ui/templates/config_preview.html @@ -28,7 +28,7 @@
- 配置叠加项 + 调试参数
{{range .ConfigSources.Overlays}}
@@ -103,7 +103,7 @@
{{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)}} -
当前运行与上一份配置回滚点的 config_id/config_version 相同,但文件内容不同,请以配置叠加项和 sha 为准。
+
当前运行与上一份配置回滚点的 config_id/config_version 相同,但文件内容不同,请以调试参数和 sha 为准。
{{end}}
{{end}} diff --git a/internal/web/ui/templates/device.html b/internal/web/ui/templates/device.html index 64f1b64..5a5aa6b 100644 --- a/internal/web/ui/templates/device.html +++ b/internal/web/ui/templates/device.html @@ -9,7 +9,7 @@
@@ -33,7 +33,7 @@ @@ -72,7 +72,7 @@
配置版本{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}
模板{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}
业务配置{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}
-
叠加项{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}
+
调试参数{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}
配置文件{{.ConfigStatus.ConfigPath}}
配置 SHA{{shortHash .ConfigStatus.Sha256}}
@@ -83,7 +83,7 @@
配置版本{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}
模板{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}
业务配置{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}
-
叠加项{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}
+
调试参数{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}
最近下发任务{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}
{{else}} @@ -106,9 +106,8 @@
媒体服务{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}未确认{{end}}
-
上一份配置{{if and .ConfigStatus .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{else}}-{{end}}
-
+
@@ -130,23 +129,38 @@
-

{{icon "apply"}}设备配置

+

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

- {{icon "preview"}}编辑和上传候选配置
-
-
当前配置{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else if .PersistedConfig}}{{.PersistedConfig.ConfigID}}{{else}}待读取{{end}}
-
候选配置{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}}{{else}}已存在{{end}}{{else}}未上传{{end}}
-
-
- - - - +
+
+ +
+ {{if .AssetProfile}} +
+ 查看所选场景配置详情 +
+
业务名称{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}
+
关联模板{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}
+
视频通道{{len .AssetProfile.Instances}} 路
+
说明{{if .AssetProfile.Description}}{{.AssetProfile.Description}}{{else}}-{{end}}
+
+
+ {{end}} +
+
+ + 预览场景配置
- +
@@ -160,13 +174,13 @@
模型入口通过模型管理页上传到设备
-
人脸库通过识别配置与资源页维护
+
人脸库通过场景配置与基础配置维护
diff --git a/internal/web/ui/templates/device_batch_config.html b/internal/web/ui/templates/device_batch_config.html index 47850b0..1e967f0 100644 --- a/internal/web/ui/templates/device_batch_config.html +++ b/internal/web/ui/templates/device_batch_config.html @@ -27,15 +27,15 @@
-

{{icon "config"}}下发业务配置

-
先选择一份已有业务配置,再为已选设备创建下发任务。
+

{{icon "config"}}下发场景配置

+
先选择一份已有场景配置,再为已选设备创建下发任务。
{{range .SelectedDeviceIDs}}{{end}}
-