Refine config assets UI and profile editor
This commit is contained in:
parent
02733c39a2
commit
165949db26
@ -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 ""
|
||||
}
|
||||
|
||||
296
internal/service/config_assets.go
Normal file
296
internal/service/config_assets.go
Normal 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
|
||||
}
|
||||
}
|
||||
237
internal/service/config_assets_test.go
Normal file
237
internal/service/config_assets_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
199
internal/service/profile_editor.go
Normal file
199
internal/service/profile_editor.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
27
internal/web/ui/templates/asset_overlay.html
Normal file
27
internal/web/ui/templates/asset_overlay.html
Normal 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}}
|
||||
34
internal/web/ui/templates/asset_overlays.html
Normal file
34
internal/web/ui/templates/asset_overlays.html
Normal 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}}
|
||||
82
internal/web/ui/templates/asset_profile.html
Normal file
82
internal/web/ui/templates/asset_profile.html
Normal 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}}
|
||||
36
internal/web/ui/templates/asset_profiles.html
Normal file
36
internal/web/ui/templates/asset_profiles.html
Normal 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}}
|
||||
40
internal/web/ui/templates/asset_template.html
Normal file
40
internal/web/ui/templates/asset_template.html
Normal 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}}
|
||||
36
internal/web/ui/templates/asset_templates.html
Normal file
36
internal/web/ui/templates/asset_templates.html
Normal 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}}
|
||||
@ -1,57 +1,132 @@
|
||||
{{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"}}
|
||||
<section class="hero-band">
|
||||
<div>
|
||||
<div class="eyebrow">配置资产</div>
|
||||
<h2>统一管理模板、环境参数、覆盖项和发布记录</h2>
|
||||
<div class="muted">这类信息只在这里集中维护,不与设备运行状态混在一起。</div>
|
||||
{{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>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<div class="quad-grid">
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>模板</span></h2>
|
||||
<div class="muted small">定义主结构,是配置生成的核心来源。</div>
|
||||
<div class="asset-stat">{{len .Templates}}</div>
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>模板</span></h2>
|
||||
</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">
|
||||
<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 "profile"}}<span>Profile</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>
|
||||
{{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 "overlay"}}<span>覆盖项</span></h2>
|
||||
<div class="muted small">表达调试、敏感度、生产静默等变化,不暴露内部算法细节。</div>
|
||||
<div class="asset-stat">Overlays</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">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>
|
||||
{{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">
|
||||
<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 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}}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user