Refine config assets UI and profile editor

This commit is contained in:
tian 2026-04-20 15:26:39 +08:00
parent 02733c39a2
commit 165949db26
21 changed files with 1699 additions and 127 deletions

View File

@ -9,6 +9,8 @@ type Device struct {
AgentPort int `json:"agent_port"`
MediaPort int `json:"media_port"`
DeviceName string `json:"device_name"`
InstanceName string `json:"instance_name,omitempty"`
InstanceDisplayName string `json:"instance_display_name,omitempty"`
Version string `json:"version"`
BuildID string `json:"build_id,omitempty"`
GitSha string `json:"git_sha"`
@ -28,3 +30,32 @@ func NewDeviceRegistry() *DeviceRegistry {
Devices: make(map[string]*Device),
}
}
func (d *Device) DisplayName() string {
if d == nil {
return ""
}
if d.InstanceDisplayName != "" {
return d.InstanceDisplayName
}
if d.DeviceName != "" {
return d.DeviceName
}
if d.DeviceID != "" {
return d.DeviceID
}
return "-"
}
func (d *Device) TechnicalName() string {
if d == nil {
return ""
}
if d.DeviceName != "" && d.DeviceName != d.InstanceDisplayName {
return d.DeviceName
}
if d.Hostname != "" && d.Hostname != d.InstanceDisplayName {
return d.Hostname
}
return ""
}

View File

@ -0,0 +1,296 @@
package service
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
type ConfigTemplateAsset struct {
Name string `json:"name"`
Path string `json:"path"`
Description string `json:"description"`
Source string `json:"source"`
NodeCount int `json:"node_count"`
EdgeCount int `json:"edge_count"`
MinIOEndpoint string `json:"minio_endpoint"`
MinIOBucket string `json:"minio_bucket"`
ExternalGetTokenURL string `json:"external_get_token_url"`
ExternalPutMessageURL string `json:"external_put_message_url"`
TenantCode string `json:"tenant_code"`
AdvancedParams map[string]any `json:"advanced_params"`
Raw map[string]any `json:"raw"`
}
type ConfigProfileAsset struct {
Name string `json:"name"`
Path string `json:"path"`
Description string `json:"description"`
QueueSize int `json:"queue_size"`
QueueStrategy string `json:"queue_strategy"`
Instances []ConfigProfileInstanceAsset `json:"instances"`
Raw map[string]any `json:"raw"`
}
type ConfigProfileInstanceAsset struct {
Name string `json:"name"`
Template string `json:"template"`
DisplayName string `json:"display_name"`
DeviceCode string `json:"device_code"`
SiteName string `json:"site_name"`
RTSPURL string `json:"rtsp_url"`
PublishHLSPath string `json:"publish_hls_path"`
PublishRTSPPort string `json:"publish_rtsp_port"`
PublishRTSPPath string `json:"publish_rtsp_path"`
ChannelNo string `json:"channel_no"`
AdvancedParams map[string]any `json:"advanced_params"`
}
type ConfigOverlayAsset struct {
Name string `json:"name"`
Path string `json:"path"`
Description string `json:"description"`
OverrideTargets []string `json:"override_targets"`
OverrideTargetNum int `json:"override_target_num"`
Raw map[string]any `json:"raw"`
}
func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) {
sources, err := s.ListSources()
if err != nil {
return nil, err
}
items := make([]ConfigTemplateAsset, 0, len(sources.Templates))
for _, source := range sources.Templates {
item, err := s.GetTemplateAsset(source.Name)
if err != nil {
continue
}
items = append(items, *item)
}
return items, nil
}
func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAsset, error) {
raw, path, err := s.readAssetJSON("templates", name)
if err != nil {
return nil, err
}
templateMap, _ := raw["template"].(map[string]any)
paramsMap, _ := raw["params"].(map[string]any)
nodes, _ := templateMap["nodes"].([]any)
edges, _ := templateMap["edges"].([]any)
advanced := cloneMap(paramsMap)
for _, key := range []string{
"minio_endpoint",
"minio_bucket",
"external_get_token_url",
"external_put_message_url",
"tenant_code",
} {
delete(advanced, key)
}
if len(advanced) == 0 {
advanced = nil
}
return &ConfigTemplateAsset{
Name: firstString(raw["name"], name),
Path: path,
Description: stringValue(raw["description"]),
Source: stringValue(raw["source"]),
NodeCount: len(nodes),
EdgeCount: len(edges),
MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]),
MinIOBucket: stringValue(paramsMap["minio_bucket"]),
ExternalGetTokenURL: stringValue(paramsMap["external_get_token_url"]),
ExternalPutMessageURL: stringValue(paramsMap["external_put_message_url"]),
TenantCode: valueString(paramsMap["tenant_code"]),
AdvancedParams: advanced,
Raw: raw,
}, nil
}
func (s *ConfigPreviewService) ListProfileAssets() ([]ConfigProfileAsset, error) {
sources, err := s.ListSources()
if err != nil {
return nil, err
}
items := make([]ConfigProfileAsset, 0, len(sources.Profiles))
for _, source := range sources.Profiles {
item, err := s.GetProfileAsset(source.Name)
if err != nil {
continue
}
items = append(items, *item)
}
return items, nil
}
func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset, error) {
raw, path, err := s.readAssetJSON("profiles", name)
if err != nil {
return nil, err
}
queueMap, _ := raw["queue"].(map[string]any)
instancesRaw, _ := raw["instances"].([]any)
instances := make([]ConfigProfileInstanceAsset, 0, len(instancesRaw))
for _, item := range instancesRaw {
instanceMap, _ := item.(map[string]any)
paramsMap, _ := instanceMap["params"].(map[string]any)
advanced := cloneMap(paramsMap)
for _, key := range []string{
"display_name",
"device_code",
"site_name",
"rtsp_url",
"publish_hls_path",
"publish_rtsp_port",
"publish_rtsp_path",
"channel_no",
} {
delete(advanced, key)
}
if len(advanced) == 0 {
advanced = nil
}
instances = append(instances, ConfigProfileInstanceAsset{
Name: stringValue(instanceMap["name"]),
Template: stringValue(instanceMap["template"]),
DisplayName: stringValue(paramsMap["display_name"]),
DeviceCode: stringValue(paramsMap["device_code"]),
SiteName: stringValue(paramsMap["site_name"]),
RTSPURL: stringValue(paramsMap["rtsp_url"]),
PublishHLSPath: stringValue(paramsMap["publish_hls_path"]),
PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]),
PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]),
ChannelNo: stringValue(paramsMap["channel_no"]),
AdvancedParams: advanced,
})
}
return &ConfigProfileAsset{
Name: firstString(raw["name"], name),
Path: path,
Description: stringValue(raw["description"]),
QueueSize: intValue(queueMap["size"]),
QueueStrategy: stringValue(queueMap["strategy"]),
Instances: instances,
Raw: raw,
}, nil
}
func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) {
sources, err := s.ListSources()
if err != nil {
return nil, err
}
items := make([]ConfigOverlayAsset, 0, len(sources.Overlays))
for _, source := range sources.Overlays {
item, err := s.GetOverlayAsset(source.Name)
if err != nil {
continue
}
items = append(items, *item)
}
return items, nil
}
func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset, error) {
raw, path, err := s.readAssetJSON("overlays", name)
if err != nil {
return nil, err
}
targets := make([]string, 0)
if overrides, ok := raw["instance_overrides"].(map[string]any); ok {
for key := range overrides {
targets = append(targets, key)
}
sort.Strings(targets)
}
return &ConfigOverlayAsset{
Name: name,
Path: path,
Description: stringValue(raw["description"]),
OverrideTargets: targets,
OverrideTargetNum: len(targets),
Raw: raw,
}, nil
}
func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) {
root := s.mediaRepoRoot()
if root == "" {
return nil, "", fmt.Errorf("media repo path is not configured")
}
if err := validateConfigName(name); err != nil {
return nil, "", err
}
path := filepath.Join(root, "configs", kind, name+".json")
body, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return nil, "", err
}
return raw, path, nil
}
func cloneMap(in map[string]any) map[string]any {
if len(in) == 0 {
return map[string]any{}
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func stringValue(v any) string {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
func valueString(v any) string {
switch value := v.(type) {
case string:
return strings.TrimSpace(value)
case float64:
if float64(int(value)) == value {
return fmt.Sprintf("%d", int(value))
}
return fmt.Sprintf("%v", value)
case int:
return fmt.Sprintf("%d", value)
case int64:
return fmt.Sprintf("%d", value)
default:
return ""
}
}
func firstString(v any, fallback string) string {
if got := stringValue(v); got != "" {
return got
}
return fallback
}
func intValue(v any) int {
switch value := v.(type) {
case int:
return value
case int64:
return int(value)
case float64:
return int(value)
default:
return 0
}
}

View File

@ -0,0 +1,237 @@
package service
import (
"encoding/json"
"path/filepath"
"testing"
"3588AdminBackend/internal/config"
)
func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{
"name": "workshop_face_shoe_alarm",
"params": {
"minio_endpoint": "http://10.0.0.49:9000",
"minio_bucket": "myminio",
"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",
"snapshot_region": "us-east-1"
},
"template": {"nodes": [], "edges": []}
}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name": "local_3588_test",
"description": "test profile",
"queue": {"size": 8, "strategy": "drop_oldest"},
"instances": [{
"name": "cam1",
"template": "workshop_face_shoe_alarm",
"params": {
"display_name": "视觉识别终端-A厂区",
"device_code": "rk3588-a-001",
"site_name": "A厂区",
"rtsp_url": "rtsp://10.0.0.1/live",
"publish_hls_path": "./web/hls/cam1/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam1",
"channel_no": "cam1",
"queue_debug": true
}
}]
}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
item, err := svc.GetProfileAsset("local_3588_test")
if err != nil {
t.Fatalf("GetProfileAsset: %v", err)
}
if item.Name != "local_3588_test" || len(item.Instances) != 1 {
t.Fatalf("unexpected profile summary: %#v", item)
}
inst := item.Instances[0]
if inst.DisplayName != "视觉识别终端-A厂区" {
t.Fatalf("expected display name to be parsed, got %#v", inst)
}
if inst.PublishRTSPPort != "8555" {
t.Fatalf("expected rtsp port to be stringified, got %#v", inst.PublishRTSPPort)
}
if _, ok := inst.AdvancedParams["queue_debug"]; !ok {
t.Fatalf("expected advanced params to preserve extra keys, got %#v", inst.AdvancedParams)
}
if item, err := svc.GetTemplateAsset("workshop_face_shoe_alarm"); err != nil {
t.Fatalf("GetTemplateAsset: %v", err)
} else {
if item.MinIOEndpoint != "http://10.0.0.49:9000" || item.TenantCode != "32" {
t.Fatalf("expected shared service params on template asset, got %#v", item)
}
if _, ok := item.AdvancedParams["snapshot_region"]; !ok {
t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams)
}
}
}
func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{
"description": "debug overlay",
"instance_overrides": {
"*": {"override": {}},
"cam1": {"override": {}}
}
}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
item, err := svc.GetOverlayAsset("face_debug")
if err != nil {
t.Fatalf("GetOverlayAsset: %v", err)
}
if item.OverrideTargetNum != 2 {
t.Fatalf("expected 2 override targets, got %#v", item)
}
if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" {
t.Fatalf("unexpected targets: %#v", item.OverrideTargets)
}
}
func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name": "local_3588_test",
"description": "test profile",
"queue": {"size": 8, "strategy": "drop_oldest"},
"instances": [{
"name": "cam1",
"template": "workshop_face_shoe_alarm",
"params": {
"display_name": "视觉识别终端-A厂区",
"device_code": "rk3588-a-001",
"site_name": "A厂区",
"rtsp_url": "rtsp://10.0.0.1/live",
"publish_hls_path": "./web/hls/cam1/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam1",
"channel_no": "cam1",
"queue_debug": true
}
}]
}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
editor, err := svc.GetProfileEditor("local_3588_test")
if err != nil {
t.Fatalf("GetProfileEditor: %v", err)
}
if editor.Name != "local_3588_test" {
t.Fatalf("unexpected profile name: %#v", editor)
}
if editor.Instance.Name != "cam1" || editor.Instance.DisplayName != "视觉识别终端-A厂区" {
t.Fatalf("unexpected instance summary: %#v", editor.Instance)
}
if editor.Instance.PublishRTSPPort != "8555" {
t.Fatalf("expected rtsp port to be stringified, got %#v", editor.Instance.PublishRTSPPort)
}
if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" {
t.Fatalf("unexpected queue model: %#v", editor.Queue)
}
if _, ok := editor.Instance.AdvancedParams["queue_debug"]; !ok {
t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instance.AdvancedParams)
}
}
func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{})
editor := ConfigProfileEditor{
Name: "local_3588_test",
Description: "test profile",
Queue: ConfigProfileQueueEditor{
Size: "8",
Strategy: "drop_oldest",
},
Instance: ConfigProfileInstanceEditor{
Name: "cam1",
Template: "workshop_face_shoe_alarm",
DisplayName: "视觉识别终端-A厂区",
DeviceCode: "rk3588-a-001",
SiteName: "A厂区",
RTSPURL: "rtsp://10.0.0.1/live",
PublishHLSPath: "./web/hls/cam1/index.m3u8",
PublishRTSPPort: "8555",
PublishRTSPPath: "/live/cam1",
ChannelNo: "cam1",
AdvancedParams: map[string]any{
"queue_debug": true,
},
},
}
doc, err := svc.BuildProfileDocument(editor)
if err != nil {
t.Fatalf("BuildProfileDocument: %v", err)
}
if doc["name"] != "local_3588_test" {
t.Fatalf("unexpected doc name: %#v", doc)
}
queue, _ := doc["queue"].(map[string]any)
if queue["size"] != 8 || queue["strategy"] != "drop_oldest" {
t.Fatalf("unexpected queue doc: %#v", queue)
}
instances, _ := doc["instances"].([]map[string]any)
if len(instances) != 1 {
t.Fatalf("expected one instance, got %#v", doc["instances"])
}
params, _ := instances[0]["params"].(map[string]any)
if params["publish_rtsp_port"] != 8555 {
t.Fatalf("expected numeric rtsp port, got %#v", params["publish_rtsp_port"])
}
if params["queue_debug"] != true {
t.Fatalf("expected advanced param to survive rebuild, got %#v", params)
}
}
func TestConfigPreviewServiceBuildProfileDocumentRejectsBadPort(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{})
_, err := svc.BuildProfileDocument(ConfigProfileEditor{
Name: "local_3588_test",
Instance: ConfigProfileInstanceEditor{
Name: "cam1",
DisplayName: "视觉识别终端-A厂区",
RTSPURL: "rtsp://10.0.0.1/live",
PublishRTSPPort: "bad-port",
},
})
if err == nil {
t.Fatal("expected invalid port error")
}
}
func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{})
doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
Name: "local_3588_test",
Instance: ConfigProfileInstanceEditor{
Name: "cam1",
DisplayName: "视觉识别终端-A厂区",
RTSPURL: "rtsp://10.0.0.1/live",
},
})
if err != nil {
t.Fatalf("BuildProfileDocument: %v", err)
}
body, err := json.Marshal(doc)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if len(body) == 0 {
t.Fatal("expected json body")
}
}

View File

@ -93,12 +93,53 @@ func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewR
if root == "" {
return nil, fmt.Errorf("media repo path is not configured")
}
templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
profilePath := filepath.Join(root, "configs", "profiles", req.Profile+".json")
return s.renderFromPaths(root, req, templatePath, profilePath)
}
func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
root := s.mediaRepoRoot()
if root == "" {
return nil, fmt.Errorf("media repo path is not configured")
}
doc, err := s.BuildProfileDocument(editor)
if err != nil {
return nil, err
}
body, err := marshalConfigJSON(doc)
if err != nil {
return nil, err
}
tempProfile, err := os.CreateTemp("", "rk3588-profile-editor-*.json")
if err != nil {
return nil, err
}
tempProfilePath := tempProfile.Name()
if _, err := tempProfile.Write(body); err != nil {
_ = tempProfile.Close()
_ = os.Remove(tempProfilePath)
return nil, err
}
_ = tempProfile.Close()
defer os.Remove(tempProfilePath)
if strings.TrimSpace(req.Profile) == "" {
req.Profile = strings.TrimSpace(editor.Name)
}
templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
return s.renderFromPaths(root, req, templatePath, tempProfilePath)
}
func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewRequest, templatePath string, profilePath string) (*ConfigPreviewResult, error) {
if err := validateConfigName(req.Template); err != nil {
return nil, fmt.Errorf("invalid template: %w", err)
}
if strings.TrimSpace(req.Profile) != "" {
if err := validateConfigName(req.Profile); err != nil {
return nil, fmt.Errorf("invalid profile: %w", err)
}
}
for _, overlay := range req.Overlays {
if err := validateConfigName(overlay); err != nil {
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
@ -110,6 +151,12 @@ func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewR
if req.ConfigVersion == "" {
req.ConfigVersion = time.Now().Format("20060102.150405")
}
if _, err := os.Stat(templatePath); err != nil {
return nil, err
}
if _, err := os.Stat(profilePath); err != nil {
return nil, fmt.Errorf("invalid profile: %w", err)
}
out, err := os.CreateTemp("", "rk3588-config-preview-*.json")
if err != nil {
@ -121,8 +168,8 @@ func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewR
args := []string{
filepath.Join(root, "tools", "render_config.py"),
"--template", filepath.Join(root, "configs", "templates", req.Template+".json"),
"--profile", filepath.Join(root, "configs", "profiles", req.Profile+".json"),
"--template", templatePath,
"--profile", profilePath,
"--out", outPath,
"--config-id", req.ConfigID,
"--config-version", req.ConfigVersion,

View File

@ -127,6 +127,13 @@ func (s *DiscoveryService) Search(timeoutMs int) ([]*models.Device, error) {
BuildID string `json:"build_id"`
GitSha string `json:"git_sha"`
UptimeSec int64 `json:"uptime_sec"`
InstanceName string `json:"instance_name"`
InstanceDisplayName string `json:"instance_display_name"`
ConfigID string `json:"config_id"`
ConfigVersion string `json:"config_version"`
Template string `json:"template"`
Profile string `json:"profile"`
Overlays []string `json:"overlays"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &reply); err != nil {
continue
@ -146,6 +153,8 @@ func (s *DiscoveryService) Search(timeoutMs int) ([]*models.Device, error) {
BuildID: reply.BuildID,
GitSha: reply.GitSha,
UptimeSec: reply.UptimeSec,
InstanceName: reply.InstanceName,
InstanceDisplayName: reply.InstanceDisplayName,
}
if dev.IP == "" {
dev.IP = raddr.IP.String()

View File

@ -0,0 +1,199 @@
package service
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type ConfigProfileEditor struct {
Name string `json:"name"`
Path string `json:"path"`
Description string `json:"description"`
Queue ConfigProfileQueueEditor `json:"queue"`
Instance ConfigProfileInstanceEditor `json:"instance"`
Raw map[string]any `json:"raw"`
}
type ConfigProfileQueueEditor struct {
Size string `json:"size"`
Strategy string `json:"strategy"`
}
type ConfigProfileInstanceEditor struct {
Name string `json:"name"`
Template string `json:"template"`
DisplayName string `json:"display_name"`
DeviceCode string `json:"device_code"`
SiteName string `json:"site_name"`
RTSPURL string `json:"rtsp_url"`
PublishHLSPath string `json:"publish_hls_path"`
PublishRTSPPort string `json:"publish_rtsp_port"`
PublishRTSPPath string `json:"publish_rtsp_path"`
ChannelNo string `json:"channel_no"`
AdvancedParams map[string]any `json:"advanced_params"`
}
func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEditor, error) {
raw, path, err := s.readAssetJSON("profiles", name)
if err != nil {
return nil, err
}
queueMap, _ := raw["queue"].(map[string]any)
instance := ConfigProfileInstanceEditor{}
instancesRaw, _ := raw["instances"].([]any)
if len(instancesRaw) > 0 {
instanceMap, _ := instancesRaw[0].(map[string]any)
paramsMap, _ := instanceMap["params"].(map[string]any)
advanced := cloneMap(paramsMap)
for _, key := range []string{
"display_name",
"device_code",
"site_name",
"rtsp_url",
"publish_hls_path",
"publish_rtsp_port",
"publish_rtsp_path",
"channel_no",
} {
delete(advanced, key)
}
if len(advanced) == 0 {
advanced = nil
}
instance = ConfigProfileInstanceEditor{
Name: stringValue(instanceMap["name"]),
Template: stringValue(instanceMap["template"]),
DisplayName: stringValue(paramsMap["display_name"]),
DeviceCode: stringValue(paramsMap["device_code"]),
SiteName: stringValue(paramsMap["site_name"]),
RTSPURL: stringValue(paramsMap["rtsp_url"]),
PublishHLSPath: stringValue(paramsMap["publish_hls_path"]),
PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]),
PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]),
ChannelNo: stringValue(paramsMap["channel_no"]),
AdvancedParams: advanced,
}
}
return &ConfigProfileEditor{
Name: firstString(raw["name"], name),
Path: path,
Description: stringValue(raw["description"]),
Queue: ConfigProfileQueueEditor{
Size: valueString(queueMap["size"]),
Strategy: stringValue(queueMap["strategy"]),
},
Instance: instance,
Raw: raw,
}, nil
}
func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor) (map[string]any, error) {
name := strings.TrimSpace(editor.Name)
if name == "" {
return nil, fmt.Errorf("profile name is required")
}
if err := validateConfigName(name); err != nil {
return nil, fmt.Errorf("invalid profile name: %w", err)
}
instanceName := strings.TrimSpace(editor.Instance.Name)
if instanceName == "" {
return nil, fmt.Errorf("instance name is required")
}
if err := validateConfigName(instanceName); err != nil {
return nil, fmt.Errorf("invalid instance name: %w", err)
}
displayName := strings.TrimSpace(editor.Instance.DisplayName)
if displayName == "" {
return nil, fmt.Errorf("display name is required")
}
rtspURL := strings.TrimSpace(editor.Instance.RTSPURL)
if rtspURL == "" {
return nil, fmt.Errorf("rtsp url is required")
}
params := map[string]any{}
setString(params, "display_name", displayName)
setString(params, "device_code", editor.Instance.DeviceCode)
setString(params, "site_name", editor.Instance.SiteName)
setString(params, "rtsp_url", rtspURL)
setString(params, "publish_hls_path", editor.Instance.PublishHLSPath)
setString(params, "publish_rtsp_path", editor.Instance.PublishRTSPPath)
setString(params, "channel_no", editor.Instance.ChannelNo)
if port := strings.TrimSpace(editor.Instance.PublishRTSPPort); port != "" {
value, err := strconv.Atoi(port)
if err != nil {
return nil, fmt.Errorf("publish rtsp port must be a number")
}
params["publish_rtsp_port"] = value
}
for key, value := range cloneMap(editor.Instance.AdvancedParams) {
params[key] = value
}
instance := map[string]any{
"name": instanceName,
"params": params,
}
setString(instance, "template", editor.Instance.Template)
doc := map[string]any{
"name": name,
"instances": []map[string]any{instance},
}
setString(doc, "description", editor.Description)
queue := map[string]any{}
if size := strings.TrimSpace(editor.Queue.Size); size != "" {
value, err := strconv.Atoi(size)
if err != nil {
return nil, fmt.Errorf("queue size must be a number")
}
queue["size"] = value
}
setString(queue, "strategy", editor.Queue.Strategy)
if len(queue) > 0 {
doc["queue"] = queue
}
return doc, nil
}
func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) error {
doc, err := s.BuildProfileDocument(editor)
if err != nil {
return err
}
root := s.mediaRepoRoot()
if root == "" {
return fmt.Errorf("media repo path is not configured")
}
path := filepath.Join(root, "configs", "profiles", strings.TrimSpace(editor.Name)+".json")
body, err := marshalConfigJSON(doc)
if err != nil {
return err
}
return os.WriteFile(path, body, 0o644)
}
func setString(m map[string]any, key string, value string) {
if strings.TrimSpace(value) != "" {
m[key] = strings.TrimSpace(value)
}
}
func marshalConfigJSON(doc map[string]any) ([]byte, error) {
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, err
}
return append(body, '\n'), nil
}

View File

@ -61,6 +61,15 @@ type PageData struct {
TaskDeviceRows []TaskDeviceRow
Templates []service.Template
Template *service.Template
AssetTab string
AssetTemplates []service.ConfigTemplateAsset
AssetTemplate *service.ConfigTemplateAsset
AssetProfiles []service.ConfigProfileAsset
AssetProfile *service.ConfigProfileAsset
AssetProfileEditor *service.ConfigProfileEditor
AssetOverlays []service.ConfigOverlayAsset
AssetOverlay *service.ConfigOverlayAsset
AssetInstanceCount int
SelectedDeviceIDs []string
SelectedDevices []*models.Device
SelectedQuery string
@ -113,6 +122,8 @@ type ConfigStatusMetadata struct {
Overlays []string `json:"overlays"`
RenderedAt string `json:"rendered_at"`
RenderedBy string `json:"rendered_by"`
InstanceName string `json:"instance_name"`
InstanceDisplayName string `json:"instance_display_name"`
}
type ConfigStatusMediaServer struct {
@ -149,6 +160,35 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
}
return v
},
"displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string {
if status != nil {
if v := strings.TrimSpace(status.Metadata.InstanceDisplayName); v != "" {
return v
}
}
if dev == nil {
return "-"
}
if v := strings.TrimSpace(dev.InstanceDisplayName); v != "" {
return v
}
if v := strings.TrimSpace(dev.DeviceName); v != "" {
return v
}
if v := strings.TrimSpace(dev.DeviceID); v != "" {
return v
}
return "-"
},
"displayDeviceTechnicalName": func(dev *models.Device) string {
if dev == nil {
return ""
}
if v := strings.TrimSpace(dev.TechnicalName()); v != "" {
return v
}
return ""
},
"taskGroupLabel": func(v any) string {
switch fmt.Sprint(v) {
case "config_apply":
@ -316,6 +356,13 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/devices", u.pageDevices)
r.Get("/devices/{id}/control", u.pageDeviceControl)
r.Get("/assets", u.pageAssets)
r.Get("/assets/templates", u.pageAssetTemplates)
r.Get("/assets/templates/{name}", u.pageAssetTemplate)
r.Get("/assets/profiles", u.pageAssetProfiles)
r.Get("/assets/profiles/{name}", u.pageAssetProfile)
r.Post("/assets/profiles/{name}", u.actionAssetProfileSave)
r.Get("/assets/overlays", u.pageAssetOverlays)
r.Get("/assets/overlays/{name}", u.pageAssetOverlay)
r.Get("/audit", u.pageAudit)
r.Get("/system", u.pageSystem)
r.Get("/device-config", u.pageDeviceConfig)
@ -938,22 +985,11 @@ func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) {
list, err := u.templates.ListTemplates()
data := PageData{Title: "识别配置", Templates: list}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "templates", data)
http.Redirect(w, r, "/ui/assets/templates", http.StatusFound)
}
func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
t, err := u.templates.GetTemplate(name)
if err != nil {
http.NotFound(w, r)
return
}
u.render(w, r, "template", PageData{Title: "配置模板详情", Template: t})
http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
}
func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
@ -977,12 +1013,172 @@ func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) {
list, err := u.listTemplatesSafe()
data := PageData{Title: "配置资产", Templates: list}
data := u.assetPageData("overview")
u.render(w, r, "assets", data)
}
func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("templates")
u.render(w, r, "asset_templates", data)
}
func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
data := u.assetPageData("templates")
item, err := u.preview.GetTemplateAsset(name)
if err != nil {
http.NotFound(w, r)
return
}
data.Title = "模板详情"
data.AssetTemplate = item
u.render(w, r, "asset_template", data)
}
func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("profiles")
u.render(w, r, "asset_profiles", data)
}
func (u *UI) pageAssetProfile(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 = "Profile 编辑"
u.render(w, r, "asset_profile", data)
}
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
editor, data, err := u.profileEditorActionData(r, name)
if err != nil {
http.NotFound(w, r)
return
}
if err := u.preview.SaveProfileEditor(editor); err != nil {
data.Error = err.Error()
u.render(w, r, "asset_profile", data)
return
}
if editor.Name != name {
data.Message = "Profile 已保存,名称已更新"
} else {
data.Message = "Profile 已保存"
}
u.render(w, r, "asset_profile", data)
}
func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("overlays")
u.render(w, r, "asset_overlays", data)
}
func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
data := u.assetPageData("overlays")
item, err := u.preview.GetOverlayAsset(name)
if err != nil {
http.NotFound(w, r)
return
}
data.Title = "Overlay 详情"
data.AssetOverlay = item
u.render(w, r, "asset_overlay", data)
}
func (u *UI) assetPageData(tab string) PageData {
data := PageData{
Title: "配置资产",
AssetTab: tab,
}
if u.preview == nil {
data.Error = "配置资产服务未初始化"
return data
}
sources, err := u.preview.ListSources()
data.ConfigSources = sources
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "assets", data)
if items, listErr := u.preview.ListTemplateAssets(); listErr == nil {
data.AssetTemplates = items
} else if data.Error == "" {
data.Error = listErr.Error()
}
if items, listErr := u.preview.ListProfileAssets(); listErr == nil {
data.AssetProfiles = items
for _, item := range items {
data.AssetInstanceCount += len(item.Instances)
}
} else if data.Error == "" {
data.Error = listErr.Error()
}
if items, listErr := u.preview.ListOverlayAssets(); listErr == nil {
data.AssetOverlays = items
} else if data.Error == "" {
data.Error = listErr.Error()
}
return data
}
func (u *UI) profileEditorPageData(name string) (PageData, error) {
data := u.assetPageData("profiles")
if u.preview == nil {
return data, fmt.Errorf("preview service not initialized")
}
editor, err := u.preview.GetProfileEditor(name)
if err != nil {
return data, err
}
data.AssetProfileEditor = editor
data.SelectedProfile = editor.Name
if editor.Instance.Template != "" {
data.SelectedTemplate = editor.Instance.Template
} else {
data.SelectedTemplate = "workshop_face_shoe_alarm"
}
return data, nil
}
func (u *UI) profileEditorActionData(r *http.Request, name string) (service.ConfigProfileEditor, PageData, error) {
data, err := u.profileEditorPageData(name)
if err != nil {
return service.ConfigProfileEditor{}, data, err
}
_ = r.ParseForm()
editor := service.ConfigProfileEditor{
Name: strings.TrimSpace(r.FormValue("profile_name")),
Description: strings.TrimSpace(r.FormValue("description")),
Queue: service.ConfigProfileQueueEditor{
Size: strings.TrimSpace(r.FormValue("queue_size")),
Strategy: strings.TrimSpace(r.FormValue("queue_strategy")),
},
Instance: service.ConfigProfileInstanceEditor{
Name: strings.TrimSpace(r.FormValue("instance_name")),
Template: strings.TrimSpace(r.FormValue("template")),
DisplayName: strings.TrimSpace(r.FormValue("display_name")),
DeviceCode: strings.TrimSpace(r.FormValue("device_code")),
SiteName: strings.TrimSpace(r.FormValue("site_name")),
RTSPURL: strings.TrimSpace(r.FormValue("rtsp_url")),
PublishHLSPath: strings.TrimSpace(r.FormValue("publish_hls_path")),
PublishRTSPPort: strings.TrimSpace(r.FormValue("publish_rtsp_port")),
PublishRTSPPath: strings.TrimSpace(r.FormValue("publish_rtsp_path")),
ChannelNo: strings.TrimSpace(r.FormValue("channel_no")),
AdvancedParams: parseAdvancedParams(strings.TrimSpace(r.FormValue("advanced_params"))),
},
}
if editor.Name == "" && data.AssetProfileEditor != nil {
editor.Name = data.AssetProfileEditor.Name
}
if editor.Instance.Template == "" && data.AssetProfileEditor != nil {
editor.Instance.Template = data.AssetProfileEditor.Instance.Template
}
data.AssetProfileEditor = &editor
data.SelectedProfile = editor.Name
return editor, data, nil
}
func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) {
@ -1044,6 +1240,12 @@ func (u *UI) loadConfigStatus(dev *models.Device) (*ConfigStatusView, string, er
if err := json.Unmarshal(body, &status); err != nil {
return nil, raw, err
}
if v := strings.TrimSpace(status.Metadata.InstanceName); v != "" {
dev.InstanceName = v
}
if v := strings.TrimSpace(status.Metadata.InstanceDisplayName); v != "" {
dev.InstanceDisplayName = v
}
return &status, raw, nil
}
@ -1267,6 +1469,21 @@ func cleanFormList(values []string) []string {
return out
}
func parseAdvancedParams(raw string) map[string]any {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var out map[string]any
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return map[string]any{}
}
if len(out) == 0 {
return nil
}
return out
}
func selectedIDsFromQuery(values []string) []string {
values = cleanFormList(values)
if len(values) == 0 {

View File

@ -129,10 +129,15 @@ tbody tr:hover{background:#f9fafb}
.device-context-icon{width:34px;height:34px;border-radius:8px;background:#e5eefc;color:#1d4ed8;display:grid;place-items:center}
.device-context-icon .ui-icon{width:18px;height:18px}
.device-tab-wrap{margin-top:16px}
.asset-tab-wrap{margin-top:0}
.device-tab-card{margin-top:-1px;padding:0}
.asset-tab-card{margin-top:-1px;padding:0}
.device-panel-body{padding:16px}
.asset-panel-body{padding:16px}
.device-panel-body>.card,.device-panel-body>details.card{margin:0 0 16px}
.asset-panel-body>.card,.asset-panel-body>details.card{margin:0 0 16px}
.device-panel-body>.card:last-child,.device-panel-body>details.card:last-child{margin-bottom:0}
.asset-panel-body>.card:last-child,.asset-panel-body>details.card:last-child{margin-bottom:0}
.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.quad-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
@ -142,6 +147,9 @@ tbody tr:hover{background:#f9fafb}
.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px}
.field-grid label span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
.field-grid .full{grid-column:1/-1}
.field-grid input,.field-grid select,.field-grid textarea{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:#fff;color:var(--text);font:inherit}
.field-grid textarea{resize:vertical;min-height:120px}
.code-input{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;line-height:1.5}
.info-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.info-list>div{padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface-soft)}
@ -158,6 +166,14 @@ tbody tr:hover{background:#f9fafb}
.asset-stat{margin-top:12px;font-size:20px;font-weight:700;color:#111827}
.asset-list{margin-top:14px;border-top:1px solid var(--border)}
.asset-row{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)}
.asset-link{color:inherit}
.asset-link:hover{background:#f8fafc}
.asset-tabs .nav-link{font-size:12px;font-weight:500;color:#64748b}
.asset-tabs .nav-link:hover{color:#334155}
.asset-tabs .nav-link.active{color:#111827}
.profile-editor-tabs .tab-content>.card{margin-top:-1px}
.nested-section{margin-top:14px;padding:14px}
.nested-section pre{margin-top:10px}
.empty-state{padding:18px;border:1px dashed var(--border-strong);border-radius:8px;background:var(--surface-soft)}
.empty-state.compact{padding:14px}

View File

@ -0,0 +1,27 @@
{{define "asset_overlay"}}
{{template "asset_tabs" .}}
{{if .AssetOverlay}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>{{.AssetOverlay.Name}}</span></h2>
</div>
<a class="btn secondary" href="/ui/assets/overlays">返回 Overlay 列表</a>
</div>
<div class="info-list">
<div><span>Overlay</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
<div><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
<div class="full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
<div class="full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
<div class="full"><span>路径</span><strong class="mono">{{.AssetOverlay.Path}}</strong></div>
</div>
</div>
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
<pre>{{json .AssetOverlay.Raw}}</pre>
</details>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -0,0 +1,34 @@
{{define "asset_overlays"}}
{{template "asset_tabs" .}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>Overlay 列表</span></h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Overlay</th>
<th>描述</th>
<th>目标</th>
</tr>
</thead>
<tbody>
{{range .AssetOverlays}}
<tr>
<td><a class="mono" href="/ui/assets/overlays/{{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</td>
</tr>
{{else}}
<tr><td colspan="3"><div class="empty-state compact"><div class="empty-title">还没有 Overlay</div></div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -0,0 +1,82 @@
{{define "asset_profile"}}
{{template "asset_tabs" .}}
{{if .AssetProfileEditor}}
<form method="post" action="/ui/assets/profiles/{{.AssetProfileEditor.Name}}">
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>{{.AssetProfileEditor.Name}}</span></h2>
</div>
<a class="btn secondary" href="/ui/assets/profiles">返回 Profile 列表</a>
</div>
</div>
<div class="card">
<div class="card-tabs profile-editor-tabs">
<ul class="nav nav-tabs asset-tabs" role="tablist" aria-label="Profile 编辑页签">
<li class="nav-item"><a href="#profile-basic" class="nav-link active" data-bs-toggle="tab">基础信息</a></li>
<li class="nav-item"><a href="#profile-source" class="nav-link" data-bs-toggle="tab">视频源</a></li>
<li class="nav-item"><a href="#profile-output" class="nav-link" data-bs-toggle="tab">输出流</a></li>
<li class="nav-item"><a href="#profile-advanced" class="nav-link" data-bs-toggle="tab">高级设置</a></li>
</ul>
<div class="tab-content">
<div id="profile-basic" class="card tab-pane active show">
<div class="card-body">
<div class="field-grid">
<label><span>Profile 名称</span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
<label><span>实例名</span><input name="instance_name" value="{{.AssetProfileEditor.Instance.Name}}" /></label>
<label><span>设备显示名</span><input name="display_name" value="{{.AssetProfileEditor.Instance.DisplayName}}" /></label>
<label><span>设备编号</span><input name="device_code" value="{{.AssetProfileEditor.Instance.DeviceCode}}" /></label>
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.Instance.SiteName}}" /></label>
<label><span>通道号</span><input name="channel_no" value="{{.AssetProfileEditor.Instance.ChannelNo}}" /></label>
<label class="full"><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
</div>
</div>
</div>
<div id="profile-source" class="card tab-pane">
<div class="card-body">
<div class="field-grid">
<label class="full"><span>RTSP 地址</span><input class="mono" name="rtsp_url" value="{{.AssetProfileEditor.Instance.RTSPURL}}" /></label>
</div>
</div>
</div>
<div id="profile-output" class="card tab-pane">
<div class="card-body">
<div class="field-grid">
<label class="full"><span>HLS 输出路径</span><input class="mono" name="publish_hls_path" value="{{.AssetProfileEditor.Instance.PublishHLSPath}}" /></label>
<label><span>RTSP 输出端口</span><input class="mono" name="publish_rtsp_port" value="{{.AssetProfileEditor.Instance.PublishRTSPPort}}" /></label>
<label><span>RTSP 输出路径</span><input class="mono" name="publish_rtsp_path" value="{{.AssetProfileEditor.Instance.PublishRTSPPath}}" /></label>
</div>
</div>
</div>
<div id="profile-advanced" class="card tab-pane">
<div class="card-body">
<div class="field-grid">
<label><span>队列大小</span><input class="mono" name="queue_size" value="{{.AssetProfileEditor.Queue.Size}}" /></label>
<label><span>队列策略</span><input name="queue_strategy" value="{{.AssetProfileEditor.Queue.Strategy}}" /></label>
<label class="full"><span>高级设置 JSON</span><textarea name="advanced_params" rows="8" class="code-input">{{if .AssetProfileEditor.Instance.AdvancedParams}}{{json .AssetProfileEditor.Instance.AdvancedParams}}{{end}}</textarea></label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button>
</div>
</div>
</form>
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
<pre>{{json .AssetProfileEditor.Raw}}</pre>
</details>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -0,0 +1,36 @@
{{define "asset_profiles"}}
{{template "asset_tabs" .}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>Profile 列表</span></h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Profile</th>
<th>描述</th>
<th>实例</th>
<th>队列</th>
</tr>
</thead>
<tbody>
{{range .AssetProfiles}}
<tr>
<td><a class="mono" href="/ui/assets/profiles/{{.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">还没有 Profile</div></div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -0,0 +1,40 @@
{{define "asset_template"}}
{{template "asset_tabs" .}}
{{if .AssetTemplate}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>{{.AssetTemplate.Name}}</span></h2>
</div>
<a class="btn secondary" href="/ui/assets/templates">返回模板列表</a>
</div>
<div class="info-list">
<div><span>模板名</span><strong class="mono">{{.AssetTemplate.Name}}</strong></div>
<div><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
<div><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
<div><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
<div><span>MinIO</span><strong class="mono">{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}</strong></div>
<div><span>Bucket</span><strong>{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}</strong></div>
<div class="full"><span>取 token 接口</span><strong class="mono">{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}</strong></div>
<div class="full"><span>告警上报接口</span><strong class="mono">{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}</strong></div>
<div><span>租户编码</span><strong>{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}</strong></div>
<div class="full"><span>描述</span><strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong></div>
<div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
</div>
</div>
{{if .AssetTemplate.AdvancedParams}}
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>高级设置</span></summary>
<pre>{{json .AssetTemplate.AdvancedParams}}</pre>
</details>
{{end}}
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
<pre>{{json .AssetTemplate.Raw}}</pre>
</details>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -0,0 +1,36 @@
{{define "asset_templates"}}
{{template "asset_tabs" .}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>模板列表</span></h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>模板</th>
<th>描述</th>
<th>结构</th>
<th>来源</th>
</tr>
</thead>
<tbody>
{{range .AssetTemplates}}
<tr>
<td><a class="mono" href="/ui/assets/templates/{{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td class="mono">{{.NodeCount}} / {{.EdgeCount}}</td>
<td class="mono">{{if .Source}}{{.Source}}{{else}}-{{end}}</td>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有模板</div></div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -1,57 +1,132 @@
{{define "assets"}}
<section class="hero-band">
<div>
<div class="eyebrow">配置资产</div>
<h2>统一管理模板、环境参数、覆盖项和发布记录</h2>
<div class="muted">这类信息只在这里集中维护,不与设备运行状态混在一起。</div>
{{define "asset_tabs"}}
<div class="card-tabs asset-tab-wrap">
<ul class="nav nav-tabs asset-tabs" role="tablist" aria-label="配置资产页面">
<li class="nav-item" role="presentation">
<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>
</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}}>Profile</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}}>Overlay</a>
</li>
</ul>
<div class="tab-content">
<div class="card tab-pane active show asset-tab-card">
<div class="card-body asset-panel-body">
{{end}}
{{define "asset_tabs_end"}}
</div>
</div>
</div>
{{end}}
{{define "assets"}}
{{template "asset_tabs" .}}
<div class="stats">
<div class="stat accent-teal">
<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>Profile</span></div>
<div class="v">{{len .AssetProfiles}}</div>
<div class="hint">设备独有参数归档</div>
</div>
<div class="stat accent-slate">
<div class="k metric-label">{{icon "overlay"}}<span>Overlay</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">当前 profile 中定义的实例数</div>
</div>
</div>
</section>
<div class="quad-grid">
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>模板</span></h2>
<div class="muted small">定义主结构,是配置生成的核心来源。</div>
<div class="asset-stat">{{len .Templates}}</div>
</div>
</div>
<div class="asset-list">
{{range .Templates}}
<div class="asset-row"><span class="mono">{{.Name}}</span><span class="muted small">已纳入模板库</span></div>
{{range .AssetTemplates}}
<a class="asset-row asset-link" href="/ui/assets/templates/{{.Name}}">
<span class="mono">{{.Name}}</span>
<span class="muted small">{{.NodeCount}} 节点 / {{.EdgeCount}} 连线</span>
</a>
{{else}}
<div class="muted">当前没有模板。</div>
<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 "profile"}}<span>Profile</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>
{{else}}
<div class="empty-state compact">
<div class="empty-title">还没有 Profile</div>
</div>
{{end}}
</div>
</div>
<div class="card">
<h2 class="title-with-icon">{{icon "profile"}}<span>环境参数</span></h2>
<div class="muted small">设备或站点差异集中管理,不再维护多份完整 JSON。</div>
<div class="asset-stat">Profiles</div>
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>Overlay</span></h2>
</div>
</div>
<div class="asset-list">
<div class="asset-row"><span>视频源</span><span class="muted small">地址与通道差异</span></div>
<div class="asset-row"><span>后台地址</span><span class="muted small">与站点环境相关</span></div>
<div class="asset-row"><span>认证与存储</span><span class="muted small">token API / MinIO</span></div>
</div>
</div>
<div class="card">
<h2 class="title-with-icon">{{icon "overlay"}}<span>覆盖项</span></h2>
<div class="muted small">表达调试、敏感度、生产静默等变化,不暴露内部算法细节。</div>
<div class="asset-stat">Overlays</div>
<div class="asset-list">
<div class="asset-row"><span>调试</span><span class="muted small">face_debug / shoe_debug</span></div>
<div class="asset-row"><span>敏感度</span><span class="muted small">test_sensitive</span></div>
<div class="asset-row"><span>生产静默</span><span class="muted small">production_quiet</span></div>
</div>
</div>
<div class="card">
<h2 class="title-with-icon">{{icon "release"}}<span>发布记录</span></h2>
<div class="muted small">记录配置是如何生成、发布到哪些设备、结果怎样。</div>
<div class="asset-stat">Releases</div>
<div class="asset-list">
<div class="asset-row"><span>模板 + 环境参数</span><span class="muted small">形成基础版本</span></div>
<div class="asset-row"><span>叠加覆盖项</span><span class="muted small">形成候选配置</span></div>
<div class="asset-row"><span>应用结果</span><span class="muted small">设备级回执与状态</span></div>
</div>
</div>
{{range .AssetOverlays}}
<a class="asset-row asset-link" href="/ui/assets/overlays/{{.Name}}">
<span>{{.Name}}</span>
<span class="muted small">{{.OverrideTargetNum}} 个目标</span>
</a>
{{else}}
<div class="empty-state compact">
<div class="empty-title">还没有 Overlay</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>Profile</span><strong>定义设备名、视频源、输出流与实例参数</strong></div>
<div><span>Overlay</span><strong>定义调试、敏感度和运行模式差异</strong></div>
<div><span>原则</span><strong>不回到手工维护多份完整 JSON</strong></div>
</div>
</div>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -11,14 +11,15 @@
{{if .Device.Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}
</div>
<div class="info-list">
<div><span>设备名</span><strong>{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</strong></div>
<div><span>设备名称</span><strong>{{displayDeviceName .Device .ConfigStatus}}</strong></div>
{{if displayDeviceTechnicalName .Device}}<div><span>设备主机名</span><strong>{{displayDeviceTechnicalName .Device}}</strong></div>{{end}}
<div><span>设备 ID</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
<div><span>管理地址</span><strong class="mono">{{.Device.IP}}:{{.Device.AgentPort}}</strong></div>
<div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div>
<div><span>最后心跳</span><strong>{{ago .Device.LastSeenMs}}</strong></div>
<div><span>版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
<div><span>Build ID</span><strong class="mono">{{if .Device.BuildID}}{{.Device.BuildID}}{{else}}-{{end}}</strong></div>
<div><span>主机名</span><strong>{{if .Device.Hostname}}{{.Device.Hostname}}{{else}}-{{end}}</strong></div>
<div><span>实例名</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}</strong></div>
</div>
</div>

View File

@ -12,7 +12,7 @@
<div class="info-list">
{{range .SelectedDevices}}
<div>
<span>{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</span>
<span>{{.DisplayName}}</span>
<strong class="mono">{{.DeviceID}}</strong>
</div>
{{else}}

View File

@ -5,8 +5,10 @@
<div class="device-context-icon">{{icon "device"}}</div>
<div>
<div class="crumb">当前设备</div>
<h2>{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</h2>
<div class="muted small mono">{{.Device.DeviceID}} · {{.Device.IP}}:{{.Device.AgentPort}}</div>
<h2>{{displayDeviceName .Device .ConfigStatus}}</h2>
<div class="muted small mono">
{{if displayDeviceTechnicalName .Device}}{{displayDeviceTechnicalName .Device}} · {{end}}{{.Device.DeviceID}} · {{.Device.IP}}:{{.Device.AgentPort}}
</div>
</div>
</div>
</div>

View File

@ -69,9 +69,9 @@
<div class="device-cell">
<div class="device-avatar">{{icon "device"}}</div>
<div>
<div class="device-name">{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</div>
<div class="device-name">{{displayDeviceName .Device .ConfigStatus}}</div>
<div class="device-meta-line">
{{if .Device.Hostname}}<span>{{.Device.Hostname}}</span>{{end}}
{{if displayDeviceTechnicalName .Device}}<span>{{displayDeviceTechnicalName .Device}}</span>{{end}}
<span class="mono">{{.Device.IP}}</span>
{{if .Device.Version}}<span class="mono">{{.Device.Version}}</span>{{end}}
{{if .Device.BuildID}}<span class="mono">#{{shortHash .Device.BuildID}}</span>{{end}}

View File

@ -14,7 +14,7 @@
<div class="brand-block">
<div class="brand-mark">AI</div>
<div>
<div class="brand-title">视觉识别运维台</div>
<div class="brand-title">视觉识别运维</div>
<div class="brand-subtitle">Fleet Operations Console</div>
</div>
</div>

View File

@ -5,6 +5,7 @@ import (
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/service"
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
@ -127,7 +128,7 @@ func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
}
body := rr.Body.String()
for _, want := range []string{
"视觉识别运维台",
"视觉识别运维台",
"配置资产",
"操作审计",
"系统",
@ -384,6 +385,100 @@ func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) {
}
}
func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) {
ui := newTestUI(t)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createProfileEditorMediaRepo(t)})
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test", nil)
req = withChiURLParam(req, "name", "local_3588_test")
rr := httptest.NewRecorder()
ui.pageAssetProfile(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
"基础信息",
"视频源",
"输出流",
"高级设置",
"保存",
"视觉识别终端-A厂区",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected profile editor page to contain %q, got:\n%s", want, body)
}
}
for _, forbidden := range []string{"生成预览", "上传为候选配置", "目标设备", "发布入口", "请在设备页中预览并下发", "下发方式"} {
if strings.Contains(body, forbidden) {
t.Fatalf("expected profile editor page to omit %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
root := createProfileEditorMediaRepo(t)
ui := newTestUI(t)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
form := url.Values{}
form.Set("profile_name", "local_3588_test")
form.Set("description", "updated profile")
form.Set("instance_name", "cam1")
form.Set("template", "workshop_face_shoe_alarm")
form.Set("display_name", "视觉识别终端-B厂区")
form.Set("device_code", "rk3588-b-002")
form.Set("site_name", "B厂区")
form.Set("channel_no", "cam1")
form.Set("rtsp_url", "rtsp://10.0.0.2/live")
form.Set("publish_hls_path", "./web/hls/cam1/index.m3u8")
form.Set("publish_rtsp_port", "8556")
form.Set("publish_rtsp_path", "/live/cam1")
form.Set("queue_size", "9")
form.Set("queue_strategy", "drop_oldest")
form.Set("advanced_params", `{"queue_debug":true}`)
req := httptest.NewRequest(http.MethodPost, "/ui/assets/profiles/local_3588_test", strings.NewReader(form.Encode()))
req = withChiURLParam(req, "name", "local_3588_test")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ui.actionAssetProfileSave(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, "Profile 已保存") {
t.Fatalf("expected save success message, got:\n%s", body)
}
raw, err := os.ReadFile(filepath.Join(root, "configs", "profiles", "local_3588_test.json"))
if err != nil {
t.Fatalf("read saved profile: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("unmarshal saved profile: %v", err)
}
if doc["description"] != "updated profile" {
t.Fatalf("unexpected description: %#v", doc)
}
queue, _ := doc["queue"].(map[string]any)
if queue["size"] != float64(9) {
t.Fatalf("expected queue size 9, got %#v", queue)
}
instances, _ := doc["instances"].([]any)
instance, _ := instances[0].(map[string]any)
params, _ := instance["params"].(map[string]any)
if params["display_name"] != "视觉识别终端-B厂区" {
t.Fatalf("expected updated display name, got %#v", params)
}
if params["publish_rtsp_port"] != float64(8556) {
t.Fatalf("expected numeric publish_rtsp_port, got %#v", params["publish_rtsp_port"])
}
}
func createBatchConfigMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
@ -422,6 +517,43 @@ with open(args.out, "w", encoding="utf-8") as fh:
return root
}
func createProfileEditorMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{
"name": "workshop_face_shoe_alarm",
"template": {"nodes": [], "edges": []}
}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name": "local_3588_test",
"description": "test profile",
"queue": {"size": 8, "strategy": "drop_oldest"},
"instances": [{
"name": "cam1",
"template": "workshop_face_shoe_alarm",
"params": {
"display_name": "视觉识别终端-A厂区",
"device_code": "rk3588-a-001",
"site_name": "A厂区",
"rtsp_url": "rtsp://10.0.0.1/live",
"publish_hls_path": "./web/hls/cam1/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam1",
"channel_no": "cam1",
"queue_debug": true
}
}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
return root
}
func withChiURLParam(req *http.Request, key string, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
func createBatchConfigBrokenMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
@ -1554,19 +1686,74 @@ func TestUI_ActionDeviceConfigCandidateApplyCanRenderControlPage(t *testing.T) {
func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_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","instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"视觉识别终端-A厂区"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil)
rr := httptest.NewRecorder()
ui.pageAssets(rr, req)
body := rr.Body.String()
for _, want := range []string{"配置资产", "模板", "环境参数", "覆盖项", "发布记录"} {
for _, want := range []string{"配置资产", "总览", "模板", "Profile", "Overlay", "local_3588_test", "workshop_face_shoe_alarm", "face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected assets HTML to contain %q", want)
}
}
}
func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","params":{"minio_endpoint":"http://10.0.0.49:9000","external_get_token_url":"http://10.0.0.49:8080/api/getToken"},"template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name":"local_3588_test",
"description":"test profile",
"queue":{"size":8,"strategy":"drop_oldest"},
"instances":[{
"name":"cam1",
"template":"workshop_face_shoe_alarm",
"params":{
"display_name":"视觉识别终端-A厂区",
"device_code":"rk3588-a-001",
"site_name":"A厂区",
"rtsp_url":"rtsp://10.0.0.1/live",
"publish_hls_path":"./web/hls/cam1/index.m3u8",
"publish_rtsp_port":8555,
"publish_rtsp_path":"/live/cam1",
"channel_no":"cam1",
"queue_debug":true
}
}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test", nil)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, func() *chi.Context {
rctx := chi.NewRouteContext()
rctx.URLParams.Add("name", "local_3588_test")
return rctx
}()))
rr := httptest.NewRecorder()
ui.pageAssetProfile(rr, req)
body := rr.Body.String()
for _, want := range []string{"Profile", "local_3588_test", "视觉识别终端-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)
}
}
for _, forbidden := range []string{"MinIO", "取 token 接口", "告警上报接口"} {
if strings.Contains(body, forbidden) {
t.Fatalf("profile asset HTML should no longer contain shared template field %q, got:\n%s", forbidden, body)
}
}
}
func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
reg := service.NewRegistryService(cfg, nil)