Add third-party service config flow

This commit is contained in:
tian 2026-04-29 10:51:13 +08:00
parent 5620aad10b
commit 12e2aac6f6
9 changed files with 873 additions and 135 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 != "" {

View File

@ -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 != "" {

View File

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

View File

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

View File

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