Add third-party service config flow
This commit is contained in:
parent
5620aad10b
commit
12e2aac6f6
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
{{define "asset_profiles"}}
|
||||
{{template "asset_tabs" .}}
|
||||
{{define "plans"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置列表</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置列表</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>业务配置</th>
|
||||
<th>场景配置</th>
|
||||
<th>描述</th>
|
||||
<th>视频通道</th>
|
||||
<th>队列</th>
|
||||
@ -19,13 +18,13 @@
|
||||
<tbody>
|
||||
{{range .AssetProfiles}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/assets/profiles?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td><a class="mono" href="/ui/plans?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td>{{len .Instances}}</td>
|
||||
<td class="mono">{{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有业务配置</div></div></td></tr>
|
||||
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景配置</div></div></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -33,23 +32,23 @@
|
||||
</div>
|
||||
|
||||
{{if .AssetProfileEditor}}
|
||||
<form method="post" action="/ui/assets/profiles/{{.AssetProfileEditor.Name}}">
|
||||
<form method="post" action="/ui/plans/{{.AssetProfileEditor.Name}}">
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/assets/profiles/{{.AssetProfileEditor.Name}}/export"
|
||||
data-export-url="/ui/plans/{{.AssetProfileEditor.Name}}/export"
|
||||
data-default-filename="{{.AssetProfileEditor.Name}}.json"
|
||||
>{{icon "apply"}}<span>另存为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label><span>业务配置名称</span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
|
||||
<label><span>场景名称</span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
|
||||
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
|
||||
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
|
||||
@ -58,6 +57,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
<span>对象存储</span>
|
||||
<select name="object_storage_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range .AssetIntegrations}}{{if eq .Type "object_storage"}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.ObjectStorageRef .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>认证服务</span>
|
||||
<select name="token_service_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range .AssetIntegrations}}{{if eq .Type "token_service"}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.TokenServiceRef .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>告警服务</span>
|
||||
<select name="alarm_service_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range .AssetIntegrations}}{{if eq .Type "alarm_service"}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.AlarmServiceRef .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
@ -134,5 +170,4 @@
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{template "asset_tabs_end" .}}
|
||||
{{end}}
|
||||
|
||||
@ -5,13 +5,16 @@
|
||||
<a href="/ui/assets" class="nav-link{{if eq .AssetTab "overview"}} active{{end}}" role="tab" {{if eq .AssetTab "overview"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>总览</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets/templates" class="nav-link{{if eq .AssetTab "templates"}} active{{end}}" role="tab" {{if eq .AssetTab "templates"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>模板</a>
|
||||
<a href="/ui/assets/video-sources" class="nav-link{{if eq .AssetTab "video-sources"}} active{{end}}" role="tab" {{if eq .AssetTab "video-sources"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>视频源</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets/profiles" class="nav-link{{if eq .AssetTab "profiles"}} active{{end}}" role="tab" {{if eq .AssetTab "profiles"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>业务配置</a>
|
||||
<a href="/ui/assets/templates" class="nav-link{{if eq .AssetTab "templates"}} active{{end}}" role="tab" {{if eq .AssetTab "templates"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>识别模板</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets/overlays" class="nav-link{{if eq .AssetTab "overlays"}} active{{end}}" role="tab" {{if eq .AssetTab "overlays"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>叠加项</a>
|
||||
<a href="/ui/assets/integrations" class="nav-link{{if eq .AssetTab "integrations"}} active{{end}}" role="tab" {{if eq .AssetTab "integrations"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>第三方服务</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets/overlays" class="nav-link{{if eq .AssetTab "overlays"}} active{{end}}" role="tab" {{if eq .AssetTab "overlays"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>调试参数</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
@ -28,26 +31,85 @@
|
||||
{{define "assets"}}
|
||||
{{template "asset_tabs" .}}
|
||||
|
||||
{{if eq .AssetTab "integrations"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务列表</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务名称</th>
|
||||
<th>服务类型</th>
|
||||
<th>描述</th>
|
||||
<th>地址摘要</th>
|
||||
<th>状态</th>
|
||||
<th>引用数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetIntegrations}}
|
||||
<tr>
|
||||
<td class="mono">{{.Name}}</td>
|
||||
<td>{{.TypeLabel}}</td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .AddressSummary}}{{.AddressSummary}}{{else}}-{{end}}</td>
|
||||
<td>{{if .Enabled}}启用{{else}}停用{{end}}</td>
|
||||
<td>{{.RefCount}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6"><div class="empty-state compact"><div class="empty-title">还没有第三方服务</div></div></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .AssetTab "video-sources"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频源</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetProfiles}}
|
||||
{{range .Instances}}
|
||||
<div class="asset-row">
|
||||
<span class="mono">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
|
||||
<span class="muted small">{{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">还没有视频源</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="stats">
|
||||
<div class="stat accent-teal">
|
||||
<div class="k metric-label">{{icon "template"}}<span>模板</span></div>
|
||||
<div class="k metric-label">{{icon "template"}}<span>识别模板</span></div>
|
||||
<div class="v">{{len .AssetTemplates}}</div>
|
||||
<div class="hint">{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}未定位到配置仓库{{end}}</div>
|
||||
</div>
|
||||
<div class="stat accent-green">
|
||||
<div class="k metric-label">{{icon "profile"}}<span>业务配置</span></div>
|
||||
<div class="v">{{len .AssetProfiles}}</div>
|
||||
<div class="hint">业务场景与通道参数</div>
|
||||
<div class="k metric-label">{{icon "device"}}<span>视频源</span></div>
|
||||
<div class="v">{{.AssetInstanceCount}}</div>
|
||||
<div class="hint">当前从运行方案中解析</div>
|
||||
</div>
|
||||
<div class="stat accent-slate">
|
||||
<div class="k metric-label">{{icon "overlay"}}<span>叠加项</span></div>
|
||||
<div class="k metric-label">{{icon "overlay"}}<span>调试参数</span></div>
|
||||
<div class="v">{{len .AssetOverlays}}</div>
|
||||
<div class="hint">调试与敏感度变化</div>
|
||||
</div>
|
||||
<div class="stat accent-amber">
|
||||
<div class="k metric-label">{{icon "release"}}<span>视频通道</span></div>
|
||||
<div class="v">{{.AssetInstanceCount}}</div>
|
||||
<div class="hint">业务配置中定义的通道数</div>
|
||||
<div class="k metric-label">{{icon "service"}}<span>第三方服务</span></div>
|
||||
<div class="v">{{len .AssetIntegrations}}</div>
|
||||
<div class="hint">告警、对象存储和认证服务</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -55,7 +117,7 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>模板</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>识别模板</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
@ -75,18 +137,20 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频源</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetProfiles}}
|
||||
<a class="asset-row asset-link" href="/ui/assets/profiles/{{.Name}}">
|
||||
<span>{{.Name}}</span>
|
||||
<span class="muted small">{{len .Instances}} 路通道</span>
|
||||
</a>
|
||||
{{range .Instances}}
|
||||
<div class="asset-row">
|
||||
<span class="mono">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
|
||||
<span class="muted small">{{if .RTSPURL}}{{.RTSPURL}}{{else}}未配置 RTSP{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">还没有业务配置</div>
|
||||
<div class="empty-title">还没有视频源</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@ -95,7 +159,27 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>叠加项</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetIntegrations}}
|
||||
<a class="asset-row asset-link" href="/ui/assets/integrations">
|
||||
<span>{{.Name}}</span>
|
||||
<span class="muted small">{{if .AddressSummary}}{{.AddressSummary}}{{else}}{{.TypeLabel}}{{end}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">还没有第三方服务</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
@ -106,26 +190,14 @@
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">还没有叠加项</div>
|
||||
<div class="empty-title">还没有调试参数</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>资产说明</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>模板</span><strong>定义处理链与共享服务接入</strong></div>
|
||||
<div><span>业务配置</span><strong>定义站点、视频源、输出流与通道参数</strong></div>
|
||||
<div><span>叠加项</span><strong>定义调试、敏感度和运行模式差异</strong></div>
|
||||
<div><span>原则</span><strong>不回到手工维护多份完整 JSON</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{template "asset_tabs_end" .}}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user