diff --git a/internal/models/device.go b/internal/models/device.go index bfbb618..715e121 100644 --- a/internal/models/device.go +++ b/internal/models/device.go @@ -3,19 +3,21 @@ package models import "sync" type Device struct { - DeviceID string `json:"device_id"` - Hostname string `json:"hostname,omitempty"` - IP string `json:"ip"` - AgentPort int `json:"agent_port"` - MediaPort int `json:"media_port"` - DeviceName string `json:"device_name"` - Version string `json:"version"` - BuildID string `json:"build_id,omitempty"` - GitSha string `json:"git_sha"` - UptimeSec int64 `json:"uptime_sec,omitempty"` - LastSeenMs int64 `json:"last_seen_ms"` - Online bool `json:"online"` - Graphs interface{} `json:"graphs,omitempty"` // 摘要或详情 + DeviceID string `json:"device_id"` + Hostname string `json:"hostname,omitempty"` + IP string `json:"ip"` + 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"` + UptimeSec int64 `json:"uptime_sec,omitempty"` + LastSeenMs int64 `json:"last_seen_ms"` + Online bool `json:"online"` + Graphs interface{} `json:"graphs,omitempty"` // 摘要或详情 } type DeviceRegistry struct { @@ -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 "" +} diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go new file mode 100644 index 0000000..de7cb13 --- /dev/null +++ b/internal/service/config_assets.go @@ -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 + } +} diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go new file mode 100644 index 0000000..c8a42eb --- /dev/null +++ b/internal/service/config_assets_test.go @@ -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") + } +} diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go index 2840229..01d541f 100644 --- a/internal/service/config_preview.go +++ b/internal/service/config_preview.go @@ -93,11 +93,52 @@ 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 err := validateConfigName(req.Profile); err != nil { - return nil, fmt.Errorf("invalid profile: %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 { @@ -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, diff --git a/internal/service/discovery.go b/internal/service/discovery.go index 28ee199..4a9d482 100644 --- a/internal/service/discovery.go +++ b/internal/service/discovery.go @@ -115,18 +115,25 @@ func (s *DiscoveryService) Search(timeoutMs int) ([]*models.Device, error) { } var reply struct { - Type string `json:"type"` - ReqID string `json:"req_id"` - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` - Hostname string `json:"hostname"` - IP string `json:"ip"` - AgentPort int `json:"agent_port"` - MediaPort int `json:"media_port"` - Version string `json:"version"` - BuildID string `json:"build_id"` - GitSha string `json:"git_sha"` - UptimeSec int64 `json:"uptime_sec"` + Type string `json:"type"` + ReqID string `json:"req_id"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + AgentPort int `json:"agent_port"` + MediaPort int `json:"media_port"` + Version string `json:"version"` + 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 @@ -136,16 +143,18 @@ func (s *DiscoveryService) Search(timeoutMs int) ([]*models.Device, error) { } dev := &models.Device{ - DeviceID: reply.DeviceID, - DeviceName: reply.DeviceName, - Hostname: reply.Hostname, - IP: reply.IP, - AgentPort: reply.AgentPort, - MediaPort: reply.MediaPort, - Version: reply.Version, - BuildID: reply.BuildID, - GitSha: reply.GitSha, - UptimeSec: reply.UptimeSec, + DeviceID: reply.DeviceID, + DeviceName: reply.DeviceName, + Hostname: reply.Hostname, + IP: reply.IP, + AgentPort: reply.AgentPort, + MediaPort: reply.MediaPort, + Version: reply.Version, + BuildID: reply.BuildID, + GitSha: reply.GitSha, + UptimeSec: reply.UptimeSec, + InstanceName: reply.InstanceName, + InstanceDisplayName: reply.InstanceDisplayName, } if dev.IP == "" { dev.IP = raddr.IP.String() diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go new file mode 100644 index 0000000..fa76e6a --- /dev/null +++ b/internal/service/profile_editor.go @@ -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 +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 9a87769..a5cb6bb 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -40,30 +40,39 @@ type PageData struct { OfflineCount int FoundCount int - Devices []*models.Device - DeviceRows []DeviceOverviewRow - AttentionDevices []*models.Device - Found []*models.Device - Device *models.Device - ConfigStatus *ConfigStatusView - ConfigStatusText string - ConfigStatusErr string - ConfigSources service.ConfigPreviewSources - ConfigPreview *service.ConfigPreviewResult - ResultTitle string - SelectedTemplate string - SelectedProfile string - SelectedOverlays []string - SelectedConfigID string - SelectedVersion string - Tasks []models.Task - Task *models.Task - TaskDeviceRows []TaskDeviceRow - Templates []service.Template - Template *service.Template - SelectedDeviceIDs []string - SelectedDevices []*models.Device - SelectedQuery string + Devices []*models.Device + DeviceRows []DeviceOverviewRow + AttentionDevices []*models.Device + Found []*models.Device + Device *models.Device + ConfigStatus *ConfigStatusView + ConfigStatusText string + ConfigStatusErr string + ConfigSources service.ConfigPreviewSources + ConfigPreview *service.ConfigPreviewResult + ResultTitle string + SelectedTemplate string + SelectedProfile string + SelectedOverlays []string + SelectedConfigID string + SelectedVersion string + Tasks []models.Task + Task *models.Task + 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 SelectedDevicesURL string BatchConfigURL string @@ -106,13 +115,15 @@ type ConfigStatusView struct { } type ConfigStatusMetadata struct { - ConfigID string `json:"config_id"` - ConfigVersion string `json:"config_version"` - Template string `json:"template"` - Profile string `json:"profile"` - Overlays []string `json:"overlays"` - RenderedAt string `json:"rendered_at"` - RenderedBy string `json:"rendered_by"` + ConfigID string `json:"config_id"` + ConfigVersion string `json:"config_version"` + Template string `json:"template"` + Profile string `json:"profile"` + 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 { diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 1872b4d..2cd4522 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -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} diff --git a/internal/web/ui/templates/asset_overlay.html b/internal/web/ui/templates/asset_overlay.html new file mode 100644 index 0000000..1164557 --- /dev/null +++ b/internal/web/ui/templates/asset_overlay.html @@ -0,0 +1,27 @@ +{{define "asset_overlay"}} +{{template "asset_tabs" .}} +{{if .AssetOverlay}} +
+
+
+

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

+
+ 返回 Overlay 列表 +
+
+
Overlay{{.AssetOverlay.Name}}
+
目标数量{{.AssetOverlay.OverrideTargetNum}}
+
描述{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}
+
作用目标{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
+
路径{{.AssetOverlay.Path}}
+
+
+ +
+ {{icon "tech"}}原始 JSON +
{{json .AssetOverlay.Raw}}
+
+{{end}} +{{if .Error}}
{{.Error}}
{{end}} +{{template "asset_tabs_end" .}} +{{end}} diff --git a/internal/web/ui/templates/asset_overlays.html b/internal/web/ui/templates/asset_overlays.html new file mode 100644 index 0000000..52601a6 --- /dev/null +++ b/internal/web/ui/templates/asset_overlays.html @@ -0,0 +1,34 @@ +{{define "asset_overlays"}} +{{template "asset_tabs" .}} +
+
+
+

{{icon "overlay"}}Overlay 列表

+
+
+
+ + + + + + + + + + {{range .AssetOverlays}} + + + + + + {{else}} + + {{end}} + +
Overlay描述目标
{{.Name}}{{if .Description}}{{.Description}}{{else}}-{{end}}{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
还没有 Overlay
+
+
+{{if .Error}}
{{.Error}}
{{end}} +{{template "asset_tabs_end" .}} +{{end}} diff --git a/internal/web/ui/templates/asset_profile.html b/internal/web/ui/templates/asset_profile.html new file mode 100644 index 0000000..02fd43e --- /dev/null +++ b/internal/web/ui/templates/asset_profile.html @@ -0,0 +1,82 @@ +{{define "asset_profile"}} +{{template "asset_tabs" .}} +{{if .AssetProfileEditor}} +
+
+
+
+

{{icon "profile"}}{{.AssetProfileEditor.Name}}

+
+ 返回 Profile 列表 +
+
+ +
+
+ +
+
+
+
+ + + + + + + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ + + +
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+ {{icon "tech"}}原始 JSON +
{{json .AssetProfileEditor.Raw}}
+
+{{end}} +{{if .Error}}
{{.Error}}
{{end}} +{{template "asset_tabs_end" .}} +{{end}} diff --git a/internal/web/ui/templates/asset_profiles.html b/internal/web/ui/templates/asset_profiles.html new file mode 100644 index 0000000..8366b42 --- /dev/null +++ b/internal/web/ui/templates/asset_profiles.html @@ -0,0 +1,36 @@ +{{define "asset_profiles"}} +{{template "asset_tabs" .}} +
+
+
+

{{icon "profile"}}Profile 列表

+
+
+
+ + + + + + + + + + + {{range .AssetProfiles}} + + + + + + + {{else}} + + {{end}} + +
Profile描述实例队列
{{.Name}}{{if .Description}}{{.Description}}{{else}}-{{end}}{{len .Instances}}{{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{else}}-{{end}}
还没有 Profile
+
+
+{{if .Error}}
{{.Error}}
{{end}} +{{template "asset_tabs_end" .}} +{{end}} diff --git a/internal/web/ui/templates/asset_template.html b/internal/web/ui/templates/asset_template.html new file mode 100644 index 0000000..d2bea1c --- /dev/null +++ b/internal/web/ui/templates/asset_template.html @@ -0,0 +1,40 @@ +{{define "asset_template"}} +{{template "asset_tabs" .}} +{{if .AssetTemplate}} +
+
+
+

{{icon "template"}}{{.AssetTemplate.Name}}

+
+ 返回模板列表 +
+
+
模板名{{.AssetTemplate.Name}}
+
来源文件{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}
+
节点数{{.AssetTemplate.NodeCount}}
+
连线数{{.AssetTemplate.EdgeCount}}
+
MinIO{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}
+
Bucket{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}
+
取 token 接口{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}
+
告警上报接口{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}
+
租户编码{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}
+
描述{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}
+
路径{{.AssetTemplate.Path}}
+
+
+ +{{if .AssetTemplate.AdvancedParams}} +
+ {{icon "tech"}}高级设置 +
{{json .AssetTemplate.AdvancedParams}}
+
+{{end}} + +
+ {{icon "tech"}}原始 JSON +
{{json .AssetTemplate.Raw}}
+
+{{end}} +{{if .Error}}
{{.Error}}
{{end}} +{{template "asset_tabs_end" .}} +{{end}} diff --git a/internal/web/ui/templates/asset_templates.html b/internal/web/ui/templates/asset_templates.html new file mode 100644 index 0000000..a9a4c5d --- /dev/null +++ b/internal/web/ui/templates/asset_templates.html @@ -0,0 +1,36 @@ +{{define "asset_templates"}} +{{template "asset_tabs" .}} +
+
+
+

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

+
+
+
+ + + + + + + + + + + {{range .AssetTemplates}} + + + + + + + {{else}} + + {{end}} + +
模板描述结构来源
{{.Name}}{{if .Description}}{{.Description}}{{else}}-{{end}}{{.NodeCount}} / {{.EdgeCount}}{{if .Source}}{{.Source}}{{else}}-{{end}}
还没有模板
+
+
+{{if .Error}}
{{.Error}}
{{end}} +{{template "asset_tabs_end" .}} +{{end}} diff --git a/internal/web/ui/templates/assets.html b/internal/web/ui/templates/assets.html index c0653d1..b26686f 100644 --- a/internal/web/ui/templates/assets.html +++ b/internal/web/ui/templates/assets.html @@ -1,57 +1,132 @@ +{{define "asset_tabs"}} +
+ +
+
+
+{{end}} + +{{define "asset_tabs_end"}} +
+
+
+{{end}} + {{define "assets"}} -
-
-
配置资产
-

统一管理模板、环境参数、覆盖项和发布记录

-
这类信息只在这里集中维护,不与设备运行状态混在一起。
+{{template "asset_tabs" .}} + +
+
+
{{icon "template"}}模板
+
{{len .AssetTemplates}}
+
{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}未定位到配置仓库{{end}}
-
+
+
{{icon "profile"}}Profile
+
{{len .AssetProfiles}}
+
设备独有参数归档
+
+
+
{{icon "overlay"}}Overlay
+
{{len .AssetOverlays}}
+
调试与敏感度变化
+
+
+
{{icon "release"}}实例
+
{{.AssetInstanceCount}}
+
当前 profile 中定义的实例数
+
+
-

{{icon "template"}}模板

-
定义主结构,是配置生成的核心来源。
-
{{len .Templates}}
+
+
+

{{icon "template"}}模板

+
+
- {{range .Templates}} -
{{.Name}}已纳入模板库
+ {{range .AssetTemplates}} + + {{.Name}} + {{.NodeCount}} 节点 / {{.EdgeCount}} 连线 + {{else}} -
当前没有模板。
+
+
还没有模板
+
{{end}}
-

{{icon "profile"}}环境参数

-
设备或站点差异集中管理,不再维护多份完整 JSON。
-
Profiles
+
+
+

{{icon "profile"}}Profile

+
+
-
视频源地址与通道差异
-
后台地址与站点环境相关
-
认证与存储token API / MinIO
+ {{range .AssetProfiles}} + + {{.Name}} + {{len .Instances}} 个实例 + + {{else}} +
+
还没有 Profile
+
+ {{end}}
-

{{icon "overlay"}}覆盖项

-
表达调试、敏感度、生产静默等变化,不暴露内部算法细节。
-
Overlays
+
+
+

{{icon "overlay"}}Overlay

+
+
-
调试face_debug / shoe_debug
-
敏感度test_sensitive
-
生产静默production_quiet
+ {{range .AssetOverlays}} + + {{.Name}} + {{.OverrideTargetNum}} 个目标 + + {{else}} +
+
还没有 Overlay
+
+ {{end}}
-

{{icon "release"}}发布记录

-
记录配置是如何生成、发布到哪些设备、结果怎样。
-
Releases
-
-
模板 + 环境参数形成基础版本
-
叠加覆盖项形成候选配置
-
应用结果设备级回执与状态
+
+
+

{{icon "config"}}资产说明

+
+
+
+
模板定义处理链与共享服务接入
+
Profile定义设备名、视频源、输出流与实例参数
+
Overlay定义调试、敏感度和运行模式差异
+
原则不回到手工维护多份完整 JSON
+ +{{if .Error}}
{{.Error}}
{{end}} +{{template "asset_tabs_end" .}} {{end}} diff --git a/internal/web/ui/templates/device.html b/internal/web/ui/templates/device.html index 12931f2..c44021c 100644 --- a/internal/web/ui/templates/device.html +++ b/internal/web/ui/templates/device.html @@ -11,14 +11,15 @@ {{if .Device.Online}}在线{{else}}离线{{end}}
-
设备名{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}
+
设备名称{{displayDeviceName .Device .ConfigStatus}}
+ {{if displayDeviceTechnicalName .Device}}
设备主机名{{displayDeviceTechnicalName .Device}}
{{end}}
设备 ID{{.Device.DeviceID}}
管理地址{{.Device.IP}}:{{.Device.AgentPort}}
视频端口{{.Device.MediaPort}}
最后心跳{{ago .Device.LastSeenMs}}
版本{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}
Build ID{{if .Device.BuildID}}{{.Device.BuildID}}{{else}}-{{end}}
-
主机名{{if .Device.Hostname}}{{.Device.Hostname}}{{else}}-{{end}}
+
实例名{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}
diff --git a/internal/web/ui/templates/device_batch_config.html b/internal/web/ui/templates/device_batch_config.html index 1502554..39c6ab1 100644 --- a/internal/web/ui/templates/device_batch_config.html +++ b/internal/web/ui/templates/device_batch_config.html @@ -12,7 +12,7 @@
{{range .SelectedDevices}}
- {{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}} + {{.DisplayName}} {{.DeviceID}}
{{else}} diff --git a/internal/web/ui/templates/device_nav.html b/internal/web/ui/templates/device_nav.html index 097123b..5f6a757 100644 --- a/internal/web/ui/templates/device_nav.html +++ b/internal/web/ui/templates/device_nav.html @@ -5,8 +5,10 @@
{{icon "device"}}
当前设备
-

{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}

-
{{.Device.DeviceID}} · {{.Device.IP}}:{{.Device.AgentPort}}
+

{{displayDeviceName .Device .ConfigStatus}}

+
+ {{if displayDeviceTechnicalName .Device}}{{displayDeviceTechnicalName .Device}} · {{end}}{{.Device.DeviceID}} · {{.Device.IP}}:{{.Device.AgentPort}} +
diff --git a/internal/web/ui/templates/devices.html b/internal/web/ui/templates/devices.html index d40b118..1fea16c 100644 --- a/internal/web/ui/templates/devices.html +++ b/internal/web/ui/templates/devices.html @@ -69,9 +69,9 @@
{{icon "device"}}
-
{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}
+
{{displayDeviceName .Device .ConfigStatus}}
- {{if .Device.Hostname}}{{.Device.Hostname}}{{end}} + {{if displayDeviceTechnicalName .Device}}{{displayDeviceTechnicalName .Device}}{{end}} {{.Device.IP}} {{if .Device.Version}}{{.Device.Version}}{{end}} {{if .Device.BuildID}}#{{shortHash .Device.BuildID}}{{end}} diff --git a/internal/web/ui/templates/layout.html b/internal/web/ui/templates/layout.html index 9e749f6..b6860ea 100644 --- a/internal/web/ui/templates/layout.html +++ b/internal/web/ui/templates/layout.html @@ -14,7 +14,7 @@
AI
-
视觉识别运维台
+
视觉识别运维平台
Fleet Operations Console
diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index d087a66..8612553 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -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)