Refactor admin IA around scene templates and assignments
This commit is contained in:
parent
5dba1dcd21
commit
03e0c2f2cc
@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
)
|
||||
|
||||
@ -118,6 +119,62 @@ type ConfigVideoSourceAsset struct {
|
||||
Raw map[string]any `json:"raw"`
|
||||
}
|
||||
|
||||
type RecognitionUnitAsset struct {
|
||||
Ref string `json:"ref"`
|
||||
SceneTemplateName string `json:"scene_template_name"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
SiteName string `json:"site_name"`
|
||||
VideoSourceRef string `json:"video_source_ref"`
|
||||
OutputChannel string `json:"output_channel"`
|
||||
RTSPPort string `json:"rtsp_port"`
|
||||
Description string `json:"description"`
|
||||
AdvancedParams map[string]any `json:"advanced_params,omitempty"`
|
||||
}
|
||||
|
||||
type DeviceAssignmentAsset struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
Description string `json:"description"`
|
||||
RecognitionUnits []string `json:"recognition_units"`
|
||||
RecognitionCount int `json:"recognition_count"`
|
||||
}
|
||||
|
||||
type DeviceAssignmentBoardStats struct {
|
||||
TotalUnits int `json:"total_units"`
|
||||
TotalDevices int `json:"total_devices"`
|
||||
AssignedUnits int `json:"assigned_units"`
|
||||
UnassignedUnits int `json:"unassigned_units"`
|
||||
AverageLoad float64 `json:"average_load"`
|
||||
OverloadedDevices int `json:"overloaded_devices"`
|
||||
}
|
||||
|
||||
type DeviceAssignmentBoardUnit struct {
|
||||
Ref string `json:"ref"`
|
||||
SceneTemplateName string `json:"scene_template_name"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
VideoSourceRef string `json:"video_source_ref"`
|
||||
OutputChannel string `json:"output_channel"`
|
||||
}
|
||||
|
||||
type DeviceAssignmentBoardCard struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
AssignedCount int `json:"assigned_count"`
|
||||
MaxUnits int `json:"max_units"`
|
||||
Status string `json:"status"`
|
||||
Units []DeviceAssignmentBoardUnit `json:"units"`
|
||||
}
|
||||
|
||||
type DeviceAssignmentBoard struct {
|
||||
MaxUnitsPerDevice int `json:"max_units_per_device"`
|
||||
Stats DeviceAssignmentBoardStats `json:"stats"`
|
||||
Cards []DeviceAssignmentBoardCard `json:"cards"`
|
||||
Unassigned []DeviceAssignmentBoardUnit `json:"unassigned"`
|
||||
}
|
||||
|
||||
type VideoSourceConfig struct {
|
||||
URL string `json:"url"`
|
||||
Resolution string `json:"resolution"`
|
||||
@ -284,42 +341,27 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
|
||||
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)
|
||||
sceneMeta, _ := instanceMap["scene_meta"].(map[string]any)
|
||||
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
|
||||
outputBindings, _ := instanceMap["output_bindings"].(map[string]any)
|
||||
advanced := cloneMap(paramsMap)
|
||||
for _, key := range []string{
|
||||
"display_name",
|
||||
"device_code",
|
||||
"site_name",
|
||||
"video_source_ref",
|
||||
"publish_hls_path",
|
||||
"publish_rtsp_port",
|
||||
"publish_rtsp_path",
|
||||
"channel_no",
|
||||
} {
|
||||
delete(advanced, key)
|
||||
}
|
||||
if len(advanced) == 0 {
|
||||
advanced = nil
|
||||
units, err := s.ListRecognitionUnits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instances := make([]ConfigProfileInstanceAsset, 0)
|
||||
for _, unit := range units {
|
||||
if unit.SceneTemplateName != name {
|
||||
continue
|
||||
}
|
||||
channel := firstString(unit.OutputChannel, unit.Name)
|
||||
instances = append(instances, ConfigProfileInstanceAsset{
|
||||
Name: stringValue(instanceMap["name"]),
|
||||
Template: stringValue(instanceMap["template"]),
|
||||
VideoSourceRef: bindingField(inputBindings, "video_input_main", "video_source_ref"),
|
||||
DisplayName: stringValue(sceneMeta["display_name"]),
|
||||
DeviceCode: stringValue(sceneMeta["device_code"]),
|
||||
SiteName: stringValue(sceneMeta["site_name"]),
|
||||
PublishHLSPath: bindingField(outputBindings, "stream_output_main", "publish_hls_path"),
|
||||
PublishRTSPPort: valueString(bindingAny(outputBindings, "stream_output_main", "publish_rtsp_port")),
|
||||
PublishRTSPPath: bindingField(outputBindings, "stream_output_main", "publish_rtsp_path"),
|
||||
ChannelNo: bindingField(outputBindings, "stream_output_main", "channel_no"),
|
||||
AdvancedParams: advanced,
|
||||
Name: unit.Name,
|
||||
Template: stringValue(raw["primary_template_name"]),
|
||||
VideoSourceRef: unit.VideoSourceRef,
|
||||
DisplayName: unit.DisplayName,
|
||||
SiteName: unit.SiteName,
|
||||
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
|
||||
PublishRTSPPort: unit.RTSPPort,
|
||||
PublishRTSPPath: "/live/" + channel,
|
||||
ChannelNo: channel,
|
||||
AdvancedParams: cloneMap(unit.AdvancedParams),
|
||||
})
|
||||
}
|
||||
return &ConfigProfileAsset{
|
||||
@ -334,6 +376,294 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) DeleteProfileAsset(name string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
units, err := s.ListRecognitionUnits()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, unit := range units {
|
||||
if unit.SceneTemplateName == name {
|
||||
return fmt.Errorf("场景模板 %q 下仍有识别单元,无法删除", name)
|
||||
}
|
||||
}
|
||||
return s.assets.DeleteProfile(name)
|
||||
}
|
||||
|
||||
func recognitionUnitRef(profileName string, unitName string) string {
|
||||
return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName)
|
||||
}
|
||||
|
||||
func parseRecognitionUnitRef(ref string) (string, string, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(ref), "::", 2)
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return "", "", fmt.Errorf("invalid recognition unit ref: %s", ref)
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) ListRecognitionUnits() ([]RecognitionUnitAsset, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
records, err := s.assets.ListRecognitionUnits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]RecognitionUnitAsset, 0, len(records))
|
||||
for _, record := range records {
|
||||
item, err := recognitionUnitFromRecord(record)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].SceneTemplateName != items[j].SceneTemplateName {
|
||||
return items[i].SceneTemplateName < items[j].SceneTemplateName
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) GetRecognitionUnit(ref string) (*RecognitionUnitAsset, error) {
|
||||
sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return recognitionUnitFromRecord(*record)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveRecognitionUnit(unit RecognitionUnitAsset, originalRef string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
sceneTemplate := strings.TrimSpace(unit.SceneTemplateName)
|
||||
if err := validateConfigName(sceneTemplate); err != nil {
|
||||
return fmt.Errorf("invalid scene template: %w", err)
|
||||
}
|
||||
unitName := strings.TrimSpace(unit.Name)
|
||||
if err := validateConfigName(unitName); err != nil {
|
||||
return fmt.Errorf("invalid recognition unit name: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(unit.VideoSourceRef) == "" {
|
||||
return fmt.Errorf("video source is required")
|
||||
}
|
||||
originalTemplate := sceneTemplate
|
||||
originalName := unitName
|
||||
if strings.TrimSpace(originalRef) != "" {
|
||||
if p, n, err := parseRecognitionUnitRef(originalRef); err == nil {
|
||||
originalTemplate = p
|
||||
originalName = n
|
||||
}
|
||||
}
|
||||
if _, err := s.GetProfileEditor(sceneTemplate); err != nil {
|
||||
return err
|
||||
}
|
||||
if sceneTemplate != originalTemplate {
|
||||
if _, err := s.GetProfileEditor(originalTemplate); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if sceneTemplate != originalTemplate || unitName != originalName {
|
||||
existing, err := s.assets.GetRecognitionUnit(sceneTemplate, unitName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
return fmt.Errorf("duplicate recognition unit name: %s", unitName)
|
||||
}
|
||||
}
|
||||
if err := s.assets.SaveRecognitionUnit(recognitionUnitToRecord(unit)); err != nil {
|
||||
return err
|
||||
}
|
||||
if sceneTemplate != originalTemplate || unitName != originalName {
|
||||
if err := s.assets.DeleteRecognitionUnit(originalTemplate, originalName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) loadRecognitionUnitEditors(sceneTemplateName string, templateName string) ([]ConfigProfileInstanceEditor, string, string, error) {
|
||||
units, err := s.ListRecognitionUnits()
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
instances := make([]ConfigProfileInstanceEditor, 0)
|
||||
siteName := ""
|
||||
deviceCode := ""
|
||||
for _, unit := range units {
|
||||
if unit.SceneTemplateName != sceneTemplateName {
|
||||
continue
|
||||
}
|
||||
instances = append(instances, recognitionUnitToInstanceEditor(unit, templateName))
|
||||
if siteName == "" && strings.TrimSpace(unit.SiteName) != "" {
|
||||
siteName = strings.TrimSpace(unit.SiteName)
|
||||
}
|
||||
if deviceCode == "" {
|
||||
if code := strings.TrimSpace(stringAny(unit.AdvancedParams["device_code"])); code != "" {
|
||||
deviceCode = code
|
||||
}
|
||||
}
|
||||
}
|
||||
return instances, siteName, deviceCode, nil
|
||||
}
|
||||
|
||||
func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName string) ConfigProfileInstanceEditor {
|
||||
channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name))
|
||||
rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555"))
|
||||
return ConfigProfileInstanceEditor{
|
||||
Name: strings.TrimSpace(unit.Name),
|
||||
Template: templateName,
|
||||
VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef),
|
||||
DisplayName: strings.TrimSpace(unit.DisplayName),
|
||||
SiteName: strings.TrimSpace(unit.SiteName),
|
||||
ChannelNo: channel,
|
||||
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
|
||||
PublishRTSPPort: rtspPort,
|
||||
PublishRTSPPath: "/live/" + channel,
|
||||
InputBindings: map[string]InputBindingEditor{
|
||||
"video_input_main": {VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef)},
|
||||
},
|
||||
OutputBindings: map[string]OutputBindingEditor{
|
||||
"stream_output_main": {
|
||||
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
|
||||
PublishRTSPPort: rtspPort,
|
||||
PublishRTSPPath: "/live/" + channel,
|
||||
ChannelNo: channel,
|
||||
},
|
||||
},
|
||||
AdvancedParams: cloneMap(unit.AdvancedParams),
|
||||
}
|
||||
}
|
||||
|
||||
func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitRecord {
|
||||
channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name))
|
||||
if channel == "" {
|
||||
channel = strings.TrimSpace(unit.Name)
|
||||
}
|
||||
rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555"))
|
||||
raw := map[string]any{
|
||||
"name": strings.TrimSpace(unit.Name),
|
||||
"scene_meta": map[string]any{
|
||||
"display_name": strings.TrimSpace(unit.DisplayName),
|
||||
"site_name": strings.TrimSpace(unit.SiteName),
|
||||
},
|
||||
"input_bindings": map[string]any{
|
||||
"video_input_main": map[string]any{
|
||||
"video_source_ref": strings.TrimSpace(unit.VideoSourceRef),
|
||||
},
|
||||
},
|
||||
"output_bindings": map[string]any{
|
||||
"stream_output_main": map[string]any{
|
||||
"publish_hls_path": "./web/hls/" + channel + "/index.m3u8",
|
||||
"publish_rtsp_port": rtspPort,
|
||||
"publish_rtsp_path": "/live/" + channel,
|
||||
"channel_no": channel,
|
||||
},
|
||||
},
|
||||
}
|
||||
if params := cloneMap(unit.AdvancedParams); len(params) > 0 {
|
||||
raw["params"] = params
|
||||
}
|
||||
body, _ := marshalConfigJSON(raw)
|
||||
return storage.RecognitionUnitRecord{
|
||||
SceneTemplateName: strings.TrimSpace(unit.SceneTemplateName),
|
||||
Name: strings.TrimSpace(unit.Name),
|
||||
DisplayName: strings.TrimSpace(unit.DisplayName),
|
||||
SiteName: strings.TrimSpace(unit.SiteName),
|
||||
VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef),
|
||||
OutputChannel: channel,
|
||||
RTSPPort: rtspPort,
|
||||
Description: strings.TrimSpace(unit.Description),
|
||||
BodyJSON: string(body),
|
||||
}
|
||||
}
|
||||
|
||||
func recognitionUnitFromRecord(record storage.RecognitionUnitRecord) (*RecognitionUnitAsset, error) {
|
||||
raw := map[string]any{}
|
||||
if strings.TrimSpace(record.BodyJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sceneMeta, _ := raw["scene_meta"].(map[string]any)
|
||||
params, _ := raw["params"].(map[string]any)
|
||||
advanced := cloneMap(params)
|
||||
if deviceCode := strings.TrimSpace(stringAny(sceneMeta["device_code"])); deviceCode != "" {
|
||||
advanced["device_code"] = deviceCode
|
||||
}
|
||||
return &RecognitionUnitAsset{
|
||||
Ref: recognitionUnitRef(record.SceneTemplateName, record.Name),
|
||||
SceneTemplateName: strings.TrimSpace(record.SceneTemplateName),
|
||||
Name: strings.TrimSpace(firstString(stringAny(raw["name"]), record.Name)),
|
||||
DisplayName: strings.TrimSpace(firstString(stringAny(sceneMeta["display_name"]), record.DisplayName)),
|
||||
SiteName: strings.TrimSpace(firstString(stringAny(sceneMeta["site_name"]), record.SiteName)),
|
||||
VideoSourceRef: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "input_bindings", "video_input_main", "video_source_ref"), record.VideoSourceRef)),
|
||||
OutputChannel: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "channel_no"), record.OutputChannel)),
|
||||
RTSPPort: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "publish_rtsp_port"), record.RTSPPort)),
|
||||
Description: strings.TrimSpace(record.Description),
|
||||
AdvancedParams: advanced,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bindingStringFromAnyMap(raw map[string]any, bindingKey string, slot string, field string) string {
|
||||
bindings, _ := raw[bindingKey].(map[string]any)
|
||||
if bindings == nil {
|
||||
return ""
|
||||
}
|
||||
return bindingStringFromBindings(bindings, slot, field)
|
||||
}
|
||||
|
||||
func bindingStringFromBindings(bindings map[string]any, slot string, field string) string {
|
||||
entry, _ := bindings[slot].(map[string]any)
|
||||
return strings.TrimSpace(stringAny(entry[field]))
|
||||
}
|
||||
|
||||
func (e *ConfigProfileEditor) RawTemplateName() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(e.PrimaryTemplateName) != "" {
|
||||
return strings.TrimSpace(e.PrimaryTemplateName)
|
||||
}
|
||||
return firstProfileTemplate(e.Instances)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) DeleteRecognitionUnit(ref string) error {
|
||||
sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if refs, err := s.deviceAssignmentsReferencingRecognitionUnit(ref); err != nil {
|
||||
return err
|
||||
} else if len(refs) > 0 {
|
||||
return fmt.Errorf("识别单元 %q 已被设备分配引用:%s", unitName, strings.Join(refs, ", "))
|
||||
}
|
||||
item, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return s.assets.DeleteRecognitionUnit(sceneTemplateName, unitName)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) {
|
||||
sources, err := s.ListSources()
|
||||
if err != nil {
|
||||
@ -372,6 +702,43 @@ func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveOverlayAsset(asset ConfigOverlayAsset, raw map[string]any) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
name := strings.TrimSpace(asset.Name)
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
raw["name"] = name
|
||||
raw["description"] = strings.TrimSpace(asset.Description)
|
||||
body, err := marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.assets.SaveOverlay(name, strings.TrimSpace(asset.Description), string(body))
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) DeleteOverlayAsset(name string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
refs, err := s.profileNamesReferencingOverlay(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
return fmt.Errorf("调试参数 %q 已被场景模板引用:%s", name, strings.Join(refs, ", "))
|
||||
}
|
||||
return s.assets.DeleteOverlay(name)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
@ -493,7 +860,7 @@ func (s *ConfigPreviewService) DeleteVideoSource(name string) error {
|
||||
return err
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
return fmt.Errorf("视频源 %q 已被场景配置引用:%s", name, strings.Join(refs, ", "))
|
||||
return fmt.Errorf("视频源 %q 已被场景模板引用:%s", name, strings.Join(refs, ", "))
|
||||
}
|
||||
return s.assets.DeleteVideoSource(name)
|
||||
}
|
||||
@ -539,6 +906,428 @@ func (s *ConfigPreviewService) DeleteIntegrationService(name string) error {
|
||||
return s.assets.DeleteIntegrationService(name)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) ListDeviceAssignments() ([]DeviceAssignmentAsset, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
records, err := s.assets.ListDeviceAssignments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]DeviceAssignmentAsset, 0, len(records))
|
||||
for _, record := range records {
|
||||
item, err := deviceAssignmentFromRecord(record)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func DefaultMaxUnitsPerDevice() int {
|
||||
return 4
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) BuildDeviceAssignmentBoard(devices []*models.Device, maxUnitsPerDevice int) (*DeviceAssignmentBoard, error) {
|
||||
assignments, err := s.ListDeviceAssignments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
units, err := s.ListRecognitionUnits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildDeviceAssignmentBoardData(devices, assignments, units, maxUnitsPerDevice), nil
|
||||
}
|
||||
|
||||
func BuildAutoDeviceAssignments(devices []*models.Device, units []RecognitionUnitAsset, maxUnitsPerDevice int) []DeviceAssignmentAsset {
|
||||
maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice)
|
||||
type card struct {
|
||||
deviceID string
|
||||
deviceName string
|
||||
profile string
|
||||
refs []string
|
||||
}
|
||||
cards := make([]*card, 0, len(devices))
|
||||
for _, dev := range devices {
|
||||
if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
|
||||
continue
|
||||
}
|
||||
cards = append(cards, &card{deviceID: strings.TrimSpace(dev.DeviceID), deviceName: dev.DisplayName()})
|
||||
}
|
||||
sort.Slice(cards, func(i, j int) bool {
|
||||
return cards[i].deviceID < cards[j].deviceID
|
||||
})
|
||||
sortedUnits := append([]RecognitionUnitAsset(nil), units...)
|
||||
sort.Slice(sortedUnits, func(i, j int) bool {
|
||||
if sortedUnits[i].SceneTemplateName != sortedUnits[j].SceneTemplateName {
|
||||
return sortedUnits[i].SceneTemplateName < sortedUnits[j].SceneTemplateName
|
||||
}
|
||||
return sortedUnits[i].Name < sortedUnits[j].Name
|
||||
})
|
||||
for _, unit := range sortedUnits {
|
||||
var target *card
|
||||
for _, candidate := range cards {
|
||||
if candidate.profile != "" && candidate.profile != unit.SceneTemplateName {
|
||||
continue
|
||||
}
|
||||
if len(candidate.refs) >= maxUnitsPerDevice {
|
||||
continue
|
||||
}
|
||||
if target == nil || len(candidate.refs) < len(target.refs) {
|
||||
target = candidate
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
continue
|
||||
}
|
||||
if target.profile == "" {
|
||||
target.profile = unit.SceneTemplateName
|
||||
}
|
||||
target.refs = append(target.refs, unit.Ref)
|
||||
}
|
||||
out := make([]DeviceAssignmentAsset, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if len(card.refs) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, DeviceAssignmentAsset{
|
||||
DeviceID: card.deviceID,
|
||||
ProfileName: card.profile,
|
||||
RecognitionUnits: append([]string(nil), card.refs...),
|
||||
RecognitionCount: len(card.refs),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].DeviceID < out[j].DeviceID })
|
||||
return out
|
||||
}
|
||||
|
||||
func BuildDeviceAssignmentBoardData(devices []*models.Device, assignments []DeviceAssignmentAsset, units []RecognitionUnitAsset, maxUnitsPerDevice int) *DeviceAssignmentBoard {
|
||||
maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice)
|
||||
unitMap := make(map[string]RecognitionUnitAsset, len(units))
|
||||
for _, unit := range units {
|
||||
unitMap[unit.Ref] = unit
|
||||
}
|
||||
deviceByID := map[string]*models.Device{}
|
||||
for _, dev := range devices {
|
||||
if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
|
||||
continue
|
||||
}
|
||||
deviceByID[strings.TrimSpace(dev.DeviceID)] = dev
|
||||
}
|
||||
assignmentByDevice := map[string]DeviceAssignmentAsset{}
|
||||
for _, assignment := range assignments {
|
||||
assignmentByDevice[strings.TrimSpace(assignment.DeviceID)] = assignment
|
||||
}
|
||||
deviceIDs := make([]string, 0, len(deviceByID)+len(assignmentByDevice))
|
||||
seenDeviceIDs := map[string]struct{}{}
|
||||
for deviceID := range deviceByID {
|
||||
seenDeviceIDs[deviceID] = struct{}{}
|
||||
deviceIDs = append(deviceIDs, deviceID)
|
||||
}
|
||||
for deviceID := range assignmentByDevice {
|
||||
if _, ok := seenDeviceIDs[deviceID]; ok {
|
||||
continue
|
||||
}
|
||||
seenDeviceIDs[deviceID] = struct{}{}
|
||||
deviceIDs = append(deviceIDs, deviceID)
|
||||
}
|
||||
sort.Strings(deviceIDs)
|
||||
|
||||
assignedRefs := map[string]struct{}{}
|
||||
cards := make([]DeviceAssignmentBoardCard, 0, len(deviceIDs))
|
||||
for _, deviceID := range deviceIDs {
|
||||
assignment := assignmentByDevice[deviceID]
|
||||
card := DeviceAssignmentBoardCard{
|
||||
DeviceID: deviceID,
|
||||
DeviceName: boardDeviceName(deviceByID[deviceID], deviceID),
|
||||
ProfileName: strings.TrimSpace(assignment.ProfileName),
|
||||
MaxUnits: maxUnitsPerDevice,
|
||||
}
|
||||
for _, ref := range assignment.RecognitionUnits {
|
||||
unit, ok := unitMap[ref]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
card.Units = append(card.Units, boardUnitFromRecognitionUnit(unit))
|
||||
assignedRefs[ref] = struct{}{}
|
||||
}
|
||||
card.AssignedCount = len(card.Units)
|
||||
card.Status = boardCardStatus(card.AssignedCount, maxUnitsPerDevice)
|
||||
cards = append(cards, card)
|
||||
}
|
||||
sort.Slice(cards, func(i, j int) bool {
|
||||
li := boardStatusRank(cards[i].Status)
|
||||
lj := boardStatusRank(cards[j].Status)
|
||||
if li != lj {
|
||||
return li < lj
|
||||
}
|
||||
if cards[i].AssignedCount != cards[j].AssignedCount {
|
||||
return cards[i].AssignedCount > cards[j].AssignedCount
|
||||
}
|
||||
return cards[i].DeviceID < cards[j].DeviceID
|
||||
})
|
||||
|
||||
unassigned := make([]DeviceAssignmentBoardUnit, 0)
|
||||
for _, unit := range units {
|
||||
if _, ok := assignedRefs[unit.Ref]; ok {
|
||||
continue
|
||||
}
|
||||
unassigned = append(unassigned, boardUnitFromRecognitionUnit(unit))
|
||||
}
|
||||
sort.Slice(unassigned, func(i, j int) bool {
|
||||
if unassigned[i].SceneTemplateName != unassigned[j].SceneTemplateName {
|
||||
return unassigned[i].SceneTemplateName < unassigned[j].SceneTemplateName
|
||||
}
|
||||
return unassigned[i].Name < unassigned[j].Name
|
||||
})
|
||||
|
||||
stats := DeviceAssignmentBoardStats{
|
||||
TotalUnits: len(units),
|
||||
TotalDevices: len(deviceIDs),
|
||||
AssignedUnits: len(assignedRefs),
|
||||
UnassignedUnits: len(unassigned),
|
||||
}
|
||||
if stats.TotalDevices > 0 {
|
||||
stats.AverageLoad = float64(stats.AssignedUnits) / float64(stats.TotalDevices)
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card.Status == "full" {
|
||||
stats.OverloadedDevices++
|
||||
}
|
||||
}
|
||||
return &DeviceAssignmentBoard{
|
||||
MaxUnitsPerDevice: maxUnitsPerDevice,
|
||||
Stats: stats,
|
||||
Cards: cards,
|
||||
Unassigned: unassigned,
|
||||
}
|
||||
}
|
||||
|
||||
func deviceAssignmentFromRecord(record storage.DeviceAssignmentRecord) (*DeviceAssignmentAsset, error) {
|
||||
raw := map[string]any{}
|
||||
if strings.TrimSpace(record.BodyJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
unitRefs := make([]string, 0)
|
||||
if items, ok := raw["recognition_units"].([]any); ok {
|
||||
for _, item := range items {
|
||||
if v := strings.TrimSpace(stringValue(item)); v != "" {
|
||||
unitRefs = append(unitRefs, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &DeviceAssignmentAsset{
|
||||
DeviceID: record.DeviceID,
|
||||
ProfileName: firstString(record.ProfileName, stringValue(raw["profile_name"])),
|
||||
Description: firstString(record.Description, stringValue(raw["description"])),
|
||||
RecognitionUnits: unitRefs,
|
||||
RecognitionCount: len(unitRefs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeMaxUnitsPerDevice(v int) int {
|
||||
if v < 1 {
|
||||
return DefaultMaxUnitsPerDevice()
|
||||
}
|
||||
if v > 8 {
|
||||
return 8
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func boardDeviceName(dev *models.Device, fallback string) string {
|
||||
if dev == nil {
|
||||
return fallback
|
||||
}
|
||||
return firstString(strings.TrimSpace(dev.DisplayName()), fallback)
|
||||
}
|
||||
|
||||
func boardUnitFromRecognitionUnit(unit RecognitionUnitAsset) DeviceAssignmentBoardUnit {
|
||||
return DeviceAssignmentBoardUnit{
|
||||
Ref: unit.Ref,
|
||||
SceneTemplateName: unit.SceneTemplateName,
|
||||
Name: unit.Name,
|
||||
DisplayName: unit.DisplayName,
|
||||
VideoSourceRef: unit.VideoSourceRef,
|
||||
OutputChannel: firstString(unit.OutputChannel, unit.Name),
|
||||
}
|
||||
}
|
||||
|
||||
func boardCardStatus(count int, max int) string {
|
||||
if count <= 0 {
|
||||
return "idle"
|
||||
}
|
||||
if count >= max {
|
||||
return "full"
|
||||
}
|
||||
if count*2 >= max {
|
||||
return "busy"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
func boardStatusRank(status string) int {
|
||||
switch status {
|
||||
case "full":
|
||||
return 0
|
||||
case "busy":
|
||||
return 1
|
||||
case "low":
|
||||
return 2
|
||||
case "idle":
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) GetDeviceAssignment(deviceID string) (*DeviceAssignmentAsset, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
record, err := s.assets.GetDeviceAssignment(strings.TrimSpace(deviceID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return deviceAssignmentFromRecord(*record)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
deviceID := strings.TrimSpace(asset.DeviceID)
|
||||
if deviceID == "" {
|
||||
return fmt.Errorf("device id is required")
|
||||
}
|
||||
if strings.TrimSpace(asset.ProfileName) == "" {
|
||||
return fmt.Errorf("scene template is required")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, ref := range asset.RecognitionUnits {
|
||||
profileName, _, err := parseRecognitionUnitRef(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profileName != asset.ProfileName {
|
||||
return fmt.Errorf("all recognition units must belong to scene template %q", asset.ProfileName)
|
||||
}
|
||||
if _, ok := seen[ref]; ok {
|
||||
return fmt.Errorf("duplicate recognition unit: %s", ref)
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
}
|
||||
raw := map[string]any{
|
||||
"device_id": deviceID,
|
||||
"profile_name": strings.TrimSpace(asset.ProfileName),
|
||||
"description": strings.TrimSpace(asset.Description),
|
||||
"recognition_units": asset.RecognitionUnits,
|
||||
}
|
||||
body, err := marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.assets.SaveDeviceAssignment(deviceID, strings.TrimSpace(asset.ProfileName), strings.TrimSpace(asset.Description), string(body))
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveDeviceAssignmentBoard(assignments map[string][]string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
existing, err := s.ListDeviceAssignments()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingByDevice := make(map[string]DeviceAssignmentAsset, len(existing))
|
||||
for _, item := range existing {
|
||||
existingByDevice[item.DeviceID] = item
|
||||
}
|
||||
for deviceID, refs := range assignments {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if deviceID == "" {
|
||||
continue
|
||||
}
|
||||
cleanRefs := make([]string, 0, len(refs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, ref := range refs {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ref]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
cleanRefs = append(cleanRefs, ref)
|
||||
}
|
||||
if len(cleanRefs) == 0 {
|
||||
if _, ok := existingByDevice[deviceID]; ok {
|
||||
if err := s.assets.DeleteDeviceAssignment(deviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
firstUnit, err := s.GetRecognitionUnit(cleanRefs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profileName := firstUnit.SceneTemplateName
|
||||
for _, ref := range cleanRefs[1:] {
|
||||
unit, err := s.GetRecognitionUnit(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if unit.SceneTemplateName != profileName {
|
||||
return fmt.Errorf("device %q contains recognition units from different scene templates", deviceID)
|
||||
}
|
||||
}
|
||||
description := existingByDevice[deviceID].Description
|
||||
if err := s.SaveDeviceAssignment(DeviceAssignmentAsset{
|
||||
DeviceID: deviceID,
|
||||
ProfileName: profileName,
|
||||
Description: description,
|
||||
RecognitionUnits: cleanRefs,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) DeleteDeviceAssignment(deviceID string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
return s.assets.DeleteDeviceAssignment(strings.TrimSpace(deviceID))
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) deviceAssignmentsReferencingRecognitionUnit(ref string) ([]string, error) {
|
||||
items, err := s.ListDeviceAssignments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0)
|
||||
for _, item := range items {
|
||||
for _, unitRef := range item.RecognitionUnits {
|
||||
if unitRef == ref {
|
||||
out = append(out, item.DeviceID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
@ -609,7 +1398,7 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
records, err := s.assets.ListProfiles()
|
||||
units, err := s.ListRecognitionUnits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -617,28 +1406,23 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
refs := make([]string, 0)
|
||||
for _, record := range records {
|
||||
var raw map[string]any
|
||||
for _, unit := range units {
|
||||
record, err := s.assets.GetRecognitionUnit(unit.SceneTemplateName, unit.Name)
|
||||
if err != nil || record == nil {
|
||||
continue
|
||||
}
|
||||
raw := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
instances, _ := raw["instances"].([]any)
|
||||
for _, item := range instances {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
|
||||
found := false
|
||||
for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} {
|
||||
if strings.TrimSpace(bindingField(serviceBindings, slot, "service_ref")) == name {
|
||||
refs = append(refs, record.Name)
|
||||
found = true
|
||||
break
|
||||
for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} {
|
||||
if strings.TrimSpace(bindingStringFromAnyMap(raw, "service_bindings", slot, "service_ref")) == name {
|
||||
if _, ok := seen[unit.SceneTemplateName]; !ok {
|
||||
seen[unit.SceneTemplateName] = struct{}{}
|
||||
refs = append(refs, unit.SceneTemplateName)
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -648,6 +1432,32 @@ func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name st
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
units, err := s.ListRecognitionUnits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
refs := make([]string, 0)
|
||||
for _, unit := range units {
|
||||
if strings.TrimSpace(unit.VideoSourceRef) == name {
|
||||
if _, ok := seen[unit.SceneTemplateName]; !ok {
|
||||
seen[unit.SceneTemplateName] = struct{}{}
|
||||
refs = append(refs, unit.SceneTemplateName)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(refs)
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) profileNamesReferencingOverlay(name string) ([]string, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@ -665,11 +1475,8 @@ func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) (
|
||||
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
instances, _ := raw["instances"].([]any)
|
||||
for _, item := range instances {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
|
||||
if strings.TrimSpace(bindingField(inputBindings, "video_input_main", "video_source_ref")) == name {
|
||||
for _, overlay := range profileOverlayNames(raw) {
|
||||
if strings.TrimSpace(overlay) == name {
|
||||
refs = append(refs, record.Name)
|
||||
break
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
)
|
||||
|
||||
@ -112,7 +113,7 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_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",
|
||||
"description": "启用人脸识别和陌生候选调试日志,用于联调和测试。",
|
||||
"instance_overrides": {
|
||||
"*": {"override": {}},
|
||||
"cam1": {"override": {}}
|
||||
@ -138,6 +139,9 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
|
||||
if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" {
|
||||
t.Fatalf("unexpected targets: %#v", item.OverrideTargets)
|
||||
}
|
||||
if item.Description != "启用人脸识别和陌生候选调试日志,用于联调和测试。" {
|
||||
t.Fatalf("expected localized overlay description, got %#v", item.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
|
||||
@ -225,12 +229,9 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
|
||||
Name: "local_3588_test",
|
||||
BusinessName: "A厂区视觉识别",
|
||||
Description: "test profile",
|
||||
OverlayName: "face_debug",
|
||||
DeviceCode: "rk3588-a-001",
|
||||
SiteName: "A厂区",
|
||||
Queue: ConfigProfileQueueEditor{
|
||||
Size: "8",
|
||||
Strategy: "drop_oldest",
|
||||
},
|
||||
Instances: []ConfigProfileInstanceEditor{
|
||||
{
|
||||
Name: "cam1",
|
||||
@ -269,6 +270,9 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
|
||||
if doc["business_name"] != "A厂区视觉识别" {
|
||||
t.Fatalf("unexpected business name: %#v", doc)
|
||||
}
|
||||
if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" {
|
||||
t.Fatalf("unexpected overlays doc: %#v", doc["overlays"])
|
||||
}
|
||||
queue, _ := doc["queue"].(map[string]any)
|
||||
if queue["size"] != 8 || queue["strategy"] != "drop_oldest" {
|
||||
t.Fatalf("unexpected queue doc: %#v", queue)
|
||||
@ -353,6 +357,45 @@ func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileDocumentDefaultsStreamOutputFromInstanceName(t *testing.T) {
|
||||
svc := NewConfigPreviewService(&config.Config{})
|
||||
doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
|
||||
Name: "line_a",
|
||||
Instances: []ConfigProfileInstanceEditor{{
|
||||
Name: "cam7",
|
||||
Template: "std_workshop_face_recognition_shoe_alarm",
|
||||
DisplayName: "B厂区通道7",
|
||||
InputBindings: map[string]InputBindingEditor{
|
||||
"video_input_main": {VideoSourceRef: "gate_cam_07"},
|
||||
},
|
||||
OutputBindings: map[string]OutputBindingEditor{
|
||||
"stream_output_main": {
|
||||
PublishRTSPPort: "8558",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildProfileDocument: %v", err)
|
||||
}
|
||||
instances, _ := doc["instances"].([]map[string]any)
|
||||
inst := instances[0]
|
||||
outputBindings, _ := inst["output_bindings"].(map[string]any)
|
||||
streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
|
||||
if streamOutput["publish_hls_path"] != "./web/hls/cam7/index.m3u8" {
|
||||
t.Fatalf("expected default hls path, got %#v", streamOutput)
|
||||
}
|
||||
if streamOutput["publish_rtsp_path"] != "/live/cam7" {
|
||||
t.Fatalf("expected default rtsp path, got %#v", streamOutput)
|
||||
}
|
||||
if streamOutput["channel_no"] != "cam7" {
|
||||
t.Fatalf("expected default channel no, got %#v", streamOutput)
|
||||
}
|
||||
if streamOutput["publish_rtsp_port"] != 8558 {
|
||||
t.Fatalf("expected explicit rtsp port to be preserved, got %#v", streamOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVideoSources(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
@ -410,7 +453,7 @@ func TestDeleteVideoSourceBlocksWhenReferenced(t *testing.T) {
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
err = svc.DeleteVideoSource("gate_cam_01")
|
||||
if err == nil || !strings.Contains(err.Error(), "已被场景配置引用") {
|
||||
if err == nil || !strings.Contains(err.Error(), "已被场景模板引用") {
|
||||
t.Fatalf("expected referenced delete to be blocked, got %v", err)
|
||||
}
|
||||
}
|
||||
@ -821,7 +864,7 @@ func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile: %v", err)
|
||||
}
|
||||
if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"template": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"template":"helmet_v2"`)) {
|
||||
if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`)) {
|
||||
t.Fatalf("expected updated profile refs, got %#v", profile)
|
||||
}
|
||||
}
|
||||
@ -876,7 +919,7 @@ func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) {
|
||||
func TestImportStandardTemplatesFromDirSyncsExisting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`)
|
||||
mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`)
|
||||
@ -894,8 +937,8 @@ func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ImportStandardTemplatesFromDir: %v", err)
|
||||
}
|
||||
if imported != 1 {
|
||||
t.Fatalf("expected one imported standard template, got %d", imported)
|
||||
if imported != 2 {
|
||||
t.Fatalf("expected two synced standard templates, got %d", imported)
|
||||
}
|
||||
face, err := repo.GetTemplate("std_face_recognition_stream")
|
||||
if err != nil || face == nil {
|
||||
@ -908,7 +951,161 @@ func TestImportStandardTemplatesFromDirSkipsExisting(t *testing.T) {
|
||||
if err != nil || serviceRecord == nil {
|
||||
t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err)
|
||||
}
|
||||
if serviceRecord.Description != "existing service" {
|
||||
t.Fatalf("expected existing template preserved, got %#v", serviceRecord)
|
||||
if serviceRecord.Description != "standard service" {
|
||||
t.Fatalf("expected existing template to sync from directory, got %#v", serviceRecord)
|
||||
}
|
||||
if !strings.Contains(serviceRecord.BodyJSON, `"description": "standard service"`) && !strings.Contains(serviceRecord.BodyJSON, `"description":"standard service"`) {
|
||||
t.Fatalf("expected synced template body, got %#v", serviceRecord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeviceAssignmentBoardDataSummarizesLoad(t *testing.T) {
|
||||
devices := []*models.Device{
|
||||
{DeviceID: "edge-01", DeviceName: "设备一"},
|
||||
{DeviceID: "edge-02", DeviceName: "设备二"},
|
||||
{DeviceID: "edge-03", DeviceName: "设备三"},
|
||||
{DeviceID: "edge-04", DeviceName: "设备四"},
|
||||
}
|
||||
units := []RecognitionUnitAsset{
|
||||
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1"},
|
||||
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2", VideoSourceRef: "vs2"},
|
||||
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3", VideoSourceRef: "vs3"},
|
||||
{Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4", VideoSourceRef: "vs4"},
|
||||
{Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5", VideoSourceRef: "vs5"},
|
||||
{Ref: "scene_a::cam6", SceneTemplateName: "scene_a", Name: "cam6", VideoSourceRef: "vs6"},
|
||||
}
|
||||
assignments := []DeviceAssignmentAsset{
|
||||
{DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3},
|
||||
{DeviceID: "edge-02", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam4"}, RecognitionCount: 1},
|
||||
{DeviceID: "edge-03", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam5", "scene_a::cam6"}, RecognitionCount: 2},
|
||||
}
|
||||
|
||||
board := BuildDeviceAssignmentBoardData(devices, assignments, units, 4)
|
||||
|
||||
if board.Stats.TotalUnits != 6 || board.Stats.TotalDevices != 4 {
|
||||
t.Fatalf("unexpected totals: %#v", board.Stats)
|
||||
}
|
||||
if board.Stats.AssignedUnits != 6 || board.Stats.UnassignedUnits != 0 {
|
||||
t.Fatalf("unexpected assignment counts: %#v", board.Stats)
|
||||
}
|
||||
if board.Stats.OverloadedDevices != 0 {
|
||||
t.Fatalf("expected no full devices in this dataset, got %#v", board.Stats)
|
||||
}
|
||||
if len(board.Cards) != 4 {
|
||||
t.Fatalf("expected four device cards, got %#v", board.Cards)
|
||||
}
|
||||
if board.Cards[0].DeviceID != "edge-01" || board.Cards[0].Status != "busy" {
|
||||
t.Fatalf("expected >50%% loaded device to sort first, got %#v", board.Cards)
|
||||
}
|
||||
if board.Cards[1].DeviceID != "edge-03" || board.Cards[1].Status != "busy" {
|
||||
t.Fatalf("expected 50%% loaded device to count as busy, got %#v", board.Cards)
|
||||
}
|
||||
if board.Cards[3].Status != "idle" {
|
||||
t.Fatalf("expected idle device card, got %#v", board.Cards[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeviceAssignmentBoardDataTreatsOverMaxAsFull(t *testing.T) {
|
||||
devices := []*models.Device{{DeviceID: "edge-01", DeviceName: "设备一"}}
|
||||
units := []RecognitionUnitAsset{
|
||||
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"},
|
||||
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"},
|
||||
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"},
|
||||
}
|
||||
assignments := []DeviceAssignmentAsset{
|
||||
{DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3},
|
||||
}
|
||||
|
||||
board := BuildDeviceAssignmentBoardData(devices, assignments, units, 2)
|
||||
|
||||
if board.Cards[0].Status != "full" {
|
||||
t.Fatalf("expected over-max card to collapse into full, got %#v", board.Cards[0])
|
||||
}
|
||||
if board.Stats.OverloadedDevices != 1 {
|
||||
t.Fatalf("expected full-device counter to include over-max card, got %#v", board.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAutoDeviceAssignmentsBalancesUnits(t *testing.T) {
|
||||
devices := []*models.Device{
|
||||
{DeviceID: "edge-01"},
|
||||
{DeviceID: "edge-02"},
|
||||
{DeviceID: "edge-03"},
|
||||
}
|
||||
units := []RecognitionUnitAsset{
|
||||
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"},
|
||||
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"},
|
||||
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"},
|
||||
{Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4"},
|
||||
{Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5"},
|
||||
}
|
||||
|
||||
assignments := BuildAutoDeviceAssignments(devices, units, 2)
|
||||
if len(assignments) != 3 {
|
||||
t.Fatalf("expected three populated device assignments, got %#v", assignments)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
total := 0
|
||||
for _, item := range assignments {
|
||||
counts[item.DeviceID] = len(item.RecognitionUnits)
|
||||
total += len(item.RecognitionUnits)
|
||||
if item.ProfileName != "scene_a" {
|
||||
t.Fatalf("expected scene template preserved, got %#v", item)
|
||||
}
|
||||
if len(item.RecognitionUnits) > 2 {
|
||||
t.Fatalf("expected max two units per device, got %#v", item)
|
||||
}
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("expected all units assigned, got %#v", assignments)
|
||||
}
|
||||
if counts["edge-01"] != 2 || counts["edge-02"] != 2 || counts["edge-03"] != 1 {
|
||||
t.Fatalf("expected balanced distribution, got %#v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDeviceAssignmentBoardPersistsAssignments(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
|
||||
SceneTemplateName: "scene_a",
|
||||
Name: "cam1",
|
||||
VideoSourceRef: "vs1",
|
||||
OutputChannel: "cam1",
|
||||
RTSPPort: "8555",
|
||||
BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveRecognitionUnit cam1: %v", err)
|
||||
}
|
||||
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
|
||||
SceneTemplateName: "scene_a",
|
||||
Name: "cam2",
|
||||
VideoSourceRef: "vs2",
|
||||
OutputChannel: "cam2",
|
||||
RTSPPort: "8555",
|
||||
BodyJSON: `{"name":"cam2","input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveRecognitionUnit cam2: %v", err)
|
||||
}
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
if err := svc.SaveDeviceAssignmentBoard(map[string][]string{
|
||||
"edge-01": []string{"scene_a::cam1", "scene_a::cam2"},
|
||||
"edge-02": []string{},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceAssignmentBoard: %v", err)
|
||||
}
|
||||
item, err := svc.GetDeviceAssignment("edge-01")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceAssignment: %v", err)
|
||||
}
|
||||
if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 2 {
|
||||
t.Fatalf("unexpected saved assignment: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ type ConfigPreviewRequest struct {
|
||||
Overlays []string
|
||||
ConfigID string
|
||||
ConfigVersion string
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type ConfigPreviewResult struct {
|
||||
@ -120,18 +121,31 @@ func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileRaw, profilePath, err := s.readAssetJSON("profiles", req.Profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
profilePath := repoAssetPath("profiles", req.Profile)
|
||||
profileRaw := map[string]any{}
|
||||
if strings.TrimSpace(req.Profile) != "" {
|
||||
editor, err := s.GetProfileEditor(req.Profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileRaw, err = s.BuildProfileDocument(*editor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
overlays := make([]runtimeOverlayInput, 0, len(req.Overlays))
|
||||
for _, overlay := range req.Overlays {
|
||||
selectedOverlays := req.Overlays
|
||||
if len(selectedOverlays) == 0 {
|
||||
selectedOverlays = profileOverlayNames(profileRaw)
|
||||
}
|
||||
overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays))
|
||||
for _, overlay := range selectedOverlays {
|
||||
raw, path, err := s.readAssetJSON("overlays", overlay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
|
||||
}
|
||||
req.Overlays = append([]string(nil), selectedOverlays...)
|
||||
return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays)
|
||||
}
|
||||
|
||||
@ -155,17 +169,87 @@ func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, r
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlays := make([]runtimeOverlayInput, 0, len(req.Overlays))
|
||||
for _, overlay := range req.Overlays {
|
||||
selectedOverlays := req.Overlays
|
||||
if len(selectedOverlays) == 0 {
|
||||
selectedOverlays = profileOverlayNames(doc)
|
||||
}
|
||||
overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays))
|
||||
for _, overlay := range selectedOverlays {
|
||||
raw, path, err := s.readAssetJSON("overlays", overlay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
|
||||
}
|
||||
req.Overlays = append([]string(nil), selectedOverlays...)
|
||||
return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) BuildProfileEditorForDeviceAssignment(deviceID string) (*ConfigProfileEditor, *DeviceAssignmentAsset, error) {
|
||||
assignment, err := s.GetDeviceAssignment(deviceID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
editor, err := s.GetProfileEditor(assignment.ProfileName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(assignment.RecognitionUnits) == 0 {
|
||||
editor.Instances = nil
|
||||
return editor, assignment, nil
|
||||
}
|
||||
selected := make(map[string]struct{}, len(assignment.RecognitionUnits))
|
||||
for _, ref := range assignment.RecognitionUnits {
|
||||
selected[ref] = struct{}{}
|
||||
}
|
||||
filtered := make([]ConfigProfileInstanceEditor, 0, len(assignment.RecognitionUnits))
|
||||
for _, inst := range editor.Instances {
|
||||
if _, ok := selected[recognitionUnitRef(editor.Name, inst.Name)]; ok {
|
||||
filtered = append(filtered, inst)
|
||||
}
|
||||
}
|
||||
editor.Instances = filtered
|
||||
return editor, assignment, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) RenderDeviceAssignment(deviceID string) (*ConfigPreviewResult, error) {
|
||||
editor, assignment, err := s.BuildProfileEditorForDeviceAssignment(deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(editor.Instances) == 0 {
|
||||
return nil, fmt.Errorf("设备 %s 还没有分配识别单元", deviceID)
|
||||
}
|
||||
templateName := firstProfileTemplate(editor.Instances)
|
||||
if strings.TrimSpace(templateName) == "" {
|
||||
templateName = editor.RawTemplateName()
|
||||
}
|
||||
return s.RenderProfileEditor(*editor, ConfigPreviewRequest{
|
||||
Template: templateName,
|
||||
Profile: assignment.ProfileName,
|
||||
ConfigID: "assignment_" + deviceID,
|
||||
ConfigVersion: time.Now().Format("20060102.150405"),
|
||||
DeviceID: deviceID,
|
||||
})
|
||||
}
|
||||
|
||||
func profileOverlayNames(raw map[string]any) []string {
|
||||
items, _ := raw["overlays"].([]any)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if v := stringAny(item); strings.TrimSpace(v) != "" {
|
||||
out = append(out, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) {
|
||||
if err := validateConfigName(req.Template); err != nil {
|
||||
return nil, fmt.Errorf("invalid template: %w", err)
|
||||
@ -190,12 +274,16 @@ func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templa
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, map[string]any{
|
||||
metadata := map[string]any{
|
||||
"config_id": req.ConfigID,
|
||||
"config_version": req.ConfigVersion,
|
||||
"rendered_at": time.Now().Format(time.RFC3339),
|
||||
"rendered_by": "managerd",
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(req.DeviceID) != "" {
|
||||
metadata["device_id"] = strings.TrimSpace(req.DeviceID)
|
||||
}
|
||||
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -203,14 +291,14 @@ func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templa
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, _ := doc["metadata"].(map[string]any)
|
||||
renderedMetadata, _ := doc["metadata"].(map[string]any)
|
||||
sum := sha256.Sum256(body)
|
||||
return &ConfigPreviewResult{
|
||||
Request: req,
|
||||
Root: previewRenderRoot(templatePath, profilePath),
|
||||
Sha256: hex.EncodeToString(sum[:]),
|
||||
Size: len(body),
|
||||
Metadata: metadata,
|
||||
Metadata: renderedMetadata,
|
||||
JSON: string(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -327,6 +327,79 @@ func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
|
||||
"name":"std_service_test_stream",
|
||||
"template":{
|
||||
"nodes":[
|
||||
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}
|
||||
],
|
||||
"edges":[]
|
||||
}
|
||||
}`)
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{
|
||||
"name":"line_a",
|
||||
"business_name":"Line A",
|
||||
"overlays":["night_relaxed"],
|
||||
"instances":[
|
||||
{
|
||||
"name":"cam1",
|
||||
"template":"std_service_test_stream",
|
||||
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}
|
||||
}
|
||||
]
|
||||
}`); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil {
|
||||
t.Fatalf("SaveOverlay: %v", err)
|
||||
}
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
|
||||
); err != nil {
|
||||
t.Fatalf("SaveVideoSource: %v", err)
|
||||
}
|
||||
|
||||
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
|
||||
mustImportAssetsFromMediaRepo(t, svc)
|
||||
result, err := svc.Render(ConfigPreviewRequest{
|
||||
Template: "std_service_test_stream",
|
||||
Profile: "line_a",
|
||||
ConfigID: "preview",
|
||||
ConfigVersion: "v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
|
||||
t.Fatalf("unmarshal render result: %v", err)
|
||||
}
|
||||
metadata, _ := doc["metadata"].(map[string]any)
|
||||
names, _ := metadata["overlays"].([]any)
|
||||
if len(names) != 1 || stringValue(names[0]) != "night_relaxed" {
|
||||
t.Fatalf("expected profile overlay metadata, got %#v", metadata["overlays"])
|
||||
}
|
||||
instances, _ := doc["instances"].([]any)
|
||||
instance, _ := instances[0].(map[string]any)
|
||||
params, _ := instance["params"].(map[string]any)
|
||||
if got := boolValue(params["debug"], false); !got {
|
||||
t.Fatalf("expected profile overlay params to merge into runtime instance, got %#v", instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) {
|
||||
svc := NewConfigPreviewService(&config.Config{})
|
||||
err := svc.SaveProfileEditor(ConfigProfileEditor{
|
||||
|
||||
@ -10,8 +10,10 @@ import (
|
||||
type ConfigProfileEditor struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
PrimaryTemplateName string `json:"primary_template_name"`
|
||||
BusinessName string `json:"business_name"`
|
||||
Description string `json:"description"`
|
||||
OverlayName string `json:"overlay_name"`
|
||||
DeviceCode string `json:"device_code"`
|
||||
SiteName string `json:"site_name"`
|
||||
Queue ConfigProfileQueueEditor `json:"queue"`
|
||||
@ -24,6 +26,13 @@ type ConfigProfileQueueEditor struct {
|
||||
Strategy string `json:"strategy"`
|
||||
}
|
||||
|
||||
func DefaultConfigProfileQueue() ConfigProfileQueueEditor {
|
||||
return ConfigProfileQueueEditor{
|
||||
Size: "8",
|
||||
Strategy: "drop_oldest",
|
||||
}
|
||||
}
|
||||
|
||||
type InputBindingEditor struct {
|
||||
VideoSourceRef string `json:"video_source_ref"`
|
||||
}
|
||||
@ -62,63 +71,20 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit
|
||||
return nil, err
|
||||
}
|
||||
queueMap, _ := raw["queue"].(map[string]any)
|
||||
instancesRaw, _ := raw["instances"].([]any)
|
||||
instances := make([]ConfigProfileInstanceEditor, 0, len(instancesRaw))
|
||||
deviceCode := ""
|
||||
siteName := ""
|
||||
for _, item := range instancesRaw {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
paramsMap, _ := instanceMap["params"].(map[string]any)
|
||||
sceneMeta, _ := instanceMap["scene_meta"].(map[string]any)
|
||||
inputBindings := parseInputBindingEditors(instanceMap["input_bindings"])
|
||||
serviceBindings := parseServiceBindingEditors(instanceMap["service_bindings"])
|
||||
outputBindings := parseOutputBindingEditors(instanceMap["output_bindings"])
|
||||
if deviceCode == "" {
|
||||
deviceCode = firstString(sceneMeta["device_code"], stringValue(paramsMap["device_code"]))
|
||||
}
|
||||
if siteName == "" {
|
||||
siteName = firstString(sceneMeta["site_name"], stringValue(paramsMap["site_name"]))
|
||||
}
|
||||
advanced := cloneMap(paramsMap)
|
||||
for _, key := range []string{
|
||||
"display_name",
|
||||
"device_code",
|
||||
"site_name",
|
||||
"video_source_ref",
|
||||
"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, ConfigProfileInstanceEditor{
|
||||
Name: stringValue(instanceMap["name"]),
|
||||
Template: stringValue(instanceMap["template"]),
|
||||
VideoSourceRef: inputBindingRef(inputBindings, "video_input_main"),
|
||||
DisplayName: stringValue(sceneMeta["display_name"]),
|
||||
SiteName: stringValue(sceneMeta["site_name"]),
|
||||
PublishHLSPath: outputBindingValue(outputBindings, "stream_output_main", "publish_hls_path"),
|
||||
PublishRTSPPort: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_port"),
|
||||
PublishRTSPPath: outputBindingValue(outputBindings, "stream_output_main", "publish_rtsp_path"),
|
||||
ChannelNo: outputBindingValue(outputBindings, "stream_output_main", "channel_no"),
|
||||
InputBindings: inputBindings,
|
||||
ServiceBindings: serviceBindings,
|
||||
OutputBindings: outputBindings,
|
||||
AdvancedParams: advanced,
|
||||
})
|
||||
templateName := stringValue(raw["primary_template_name"])
|
||||
instances, siteName, deviceCode, err := s.loadRecognitionUnitEditors(name, templateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ConfigProfileEditor{
|
||||
Name: firstString(raw["name"], name),
|
||||
Path: path,
|
||||
BusinessName: stringValue(raw["business_name"]),
|
||||
Description: stringValue(raw["description"]),
|
||||
DeviceCode: deviceCode,
|
||||
SiteName: siteName,
|
||||
Name: firstString(raw["name"], name),
|
||||
Path: path,
|
||||
PrimaryTemplateName: stringValue(raw["primary_template_name"]),
|
||||
BusinessName: stringValue(raw["business_name"]),
|
||||
Description: stringValue(raw["description"]),
|
||||
OverlayName: firstOverlayName(raw),
|
||||
DeviceCode: deviceCode,
|
||||
SiteName: siteName,
|
||||
Queue: ConfigProfileQueueEditor{
|
||||
Size: valueString(queueMap["size"]),
|
||||
Strategy: stringValue(queueMap["strategy"]),
|
||||
@ -137,9 +103,6 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
|
||||
return nil, fmt.Errorf("invalid profile name: %w", err)
|
||||
}
|
||||
|
||||
if len(editor.Instances) == 0 {
|
||||
return nil, fmt.Errorf("at least one instance is required")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
instances := make([]map[string]any, 0, len(editor.Instances))
|
||||
for _, inst := range editor.Instances {
|
||||
@ -202,25 +165,28 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
|
||||
}
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
if len(instances) == 0 {
|
||||
return nil, fmt.Errorf("at least one active instance is required")
|
||||
}
|
||||
|
||||
doc := map[string]any{
|
||||
"name": name,
|
||||
"instances": instances,
|
||||
}
|
||||
setString(doc, "business_name", editor.BusinessName)
|
||||
setString(doc, "description", editor.Description)
|
||||
if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" {
|
||||
doc["overlays"] = []any{overlayName}
|
||||
}
|
||||
normalizedQueue := editor.Queue
|
||||
if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" {
|
||||
normalizedQueue = DefaultConfigProfileQueue()
|
||||
}
|
||||
queue := map[string]any{}
|
||||
if size := strings.TrimSpace(editor.Queue.Size); size != "" {
|
||||
if size := strings.TrimSpace(normalizedQueue.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)
|
||||
setString(queue, "strategy", normalizedQueue.Strategy)
|
||||
if len(queue) > 0 {
|
||||
doc["queue"] = queue
|
||||
}
|
||||
@ -229,7 +195,7 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) error {
|
||||
doc, err := s.BuildProfileDocument(editor)
|
||||
doc, err := s.buildSceneTemplateDocument(editor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -238,9 +204,13 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err
|
||||
return err
|
||||
}
|
||||
if s != nil && s.assets != nil {
|
||||
return s.assets.SaveProfile(
|
||||
strings.TrimSpace(editor.Name),
|
||||
firstProfileTemplate(editor.Instances),
|
||||
templateName := strings.TrimSpace(editor.PrimaryTemplateName)
|
||||
if templateName == "" {
|
||||
templateName = firstProfileTemplate(editor.Instances)
|
||||
}
|
||||
return s.assets.SaveProfile(
|
||||
strings.TrimSpace(editor.Name),
|
||||
templateName,
|
||||
strings.TrimSpace(editor.BusinessName),
|
||||
strings.TrimSpace(editor.Description),
|
||||
string(body),
|
||||
@ -249,12 +219,59 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err
|
||||
return fmt.Errorf("asset repository is not configured")
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) buildSceneTemplateDocument(editor ConfigProfileEditor) (map[string]any, error) {
|
||||
name := strings.TrimSpace(editor.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("scene template name is required")
|
||||
}
|
||||
if err := validateConfigName(name); err != nil {
|
||||
return nil, fmt.Errorf("invalid scene template name: %w", err)
|
||||
}
|
||||
doc := map[string]any{
|
||||
"name": name,
|
||||
}
|
||||
setString(doc, "primary_template_name", editor.PrimaryTemplateName)
|
||||
setString(doc, "business_name", editor.BusinessName)
|
||||
setString(doc, "description", editor.Description)
|
||||
setString(doc, "site_name", editor.SiteName)
|
||||
if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" {
|
||||
doc["overlays"] = []any{overlayName}
|
||||
}
|
||||
normalizedQueue := editor.Queue
|
||||
if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" {
|
||||
normalizedQueue = DefaultConfigProfileQueue()
|
||||
}
|
||||
queue := map[string]any{}
|
||||
if size := strings.TrimSpace(normalizedQueue.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", normalizedQueue.Strategy)
|
||||
if len(queue) > 0 {
|
||||
doc["queue"] = queue
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func setString(m map[string]any, key string, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
m[key] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
func firstOverlayName(raw map[string]any) string {
|
||||
items, _ := raw["overlays"].([]any)
|
||||
for _, item := range items {
|
||||
if v := stringValue(item); strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func marshalConfigJSON(doc map[string]any) ([]byte, error) {
|
||||
body, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
@ -390,6 +407,9 @@ func buildServiceBindingDocument(inst ConfigProfileInstanceEditor) map[string]an
|
||||
func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
for key, value := range inst.OutputBindings {
|
||||
if key == "stream_output_main" {
|
||||
value = applyDefaultStreamOutputBinding(inst.Name, value)
|
||||
}
|
||||
entry, err := outputBindingEntry(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -399,12 +419,12 @@ func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]an
|
||||
}
|
||||
}
|
||||
if _, ok := out["stream_output_main"]; !ok {
|
||||
entry, err := outputBindingEntry(OutputBindingEditor{
|
||||
entry, err := outputBindingEntry(applyDefaultStreamOutputBinding(inst.Name, OutputBindingEditor{
|
||||
PublishHLSPath: inst.PublishHLSPath,
|
||||
PublishRTSPPort: inst.PublishRTSPPort,
|
||||
PublishRTSPPath: inst.PublishRTSPPath,
|
||||
ChannelNo: inst.ChannelNo,
|
||||
})
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -418,6 +438,23 @@ func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]an
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyDefaultStreamOutputBinding(instanceName string, value OutputBindingEditor) OutputBindingEditor {
|
||||
name := strings.TrimSpace(instanceName)
|
||||
if name == "" {
|
||||
return value
|
||||
}
|
||||
if strings.TrimSpace(value.PublishHLSPath) == "" {
|
||||
value.PublishHLSPath = "./web/hls/" + name + "/index.m3u8"
|
||||
}
|
||||
if strings.TrimSpace(value.PublishRTSPPath) == "" {
|
||||
value.PublishRTSPPath = "/live/" + name
|
||||
}
|
||||
if strings.TrimSpace(value.ChannelNo) == "" {
|
||||
value.ChannelNo = name
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) {
|
||||
entry := map[string]any{}
|
||||
setString(entry, "publish_hls_path", value.PublishHLSPath)
|
||||
|
||||
@ -52,13 +52,6 @@ func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int,
|
||||
if !isStandardTemplateName(name) {
|
||||
return imported, fmt.Errorf("%s: standard template name must start with std_", path)
|
||||
}
|
||||
existing, err := repo.GetTemplate(name)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
if existing != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(stringValue(raw["source"])) == "" {
|
||||
raw["source"] = "standard"
|
||||
}
|
||||
@ -66,6 +59,15 @@ func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int,
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
existing, err := repo.GetTemplate(name)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
if existing != nil &&
|
||||
strings.TrimSpace(existing.Description) == strings.TrimSpace(stringValue(raw["description"])) &&
|
||||
strings.TrimSpace(existing.BodyJSON) == strings.TrimSpace(string(body)) {
|
||||
continue
|
||||
}
|
||||
if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil {
|
||||
return imported, err
|
||||
}
|
||||
|
||||
@ -200,6 +200,23 @@ func extractConfigPayload(payload any) (any, error) {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func extractConfigPayloadForDevice(payload any, deviceID string) (any, error) {
|
||||
if payload == nil {
|
||||
return nil, fmt.Errorf("payload is required")
|
||||
}
|
||||
if m, ok := payload.(map[string]any); ok {
|
||||
if configs, exists := m["configs"]; exists {
|
||||
if byDevice, ok := configs.(map[string]any); ok {
|
||||
if v, ok := byDevice[deviceID]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, fmt.Errorf("device assignment config is missing for %s", deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return extractConfigPayload(payload)
|
||||
}
|
||||
|
||||
func optionalConfigRequestBody(payload any) (io.Reader, int64, error) {
|
||||
if payload == nil {
|
||||
return nil, 0, nil
|
||||
@ -254,7 +271,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
|
||||
|
||||
switch task.Type {
|
||||
case "config_apply":
|
||||
cfgPayload, err := extractConfigPayload(task.Payload)
|
||||
cfgPayload, err := extractConfigPayloadForDevice(task.Payload, did)
|
||||
if err != nil {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
@ -399,7 +416,7 @@ func (s *TaskService) persistConfigState(task *models.Task, did string) {
|
||||
if s == nil || s.stateRepo == nil || task == nil || task.Type != "config_apply" {
|
||||
return
|
||||
}
|
||||
meta := taskPayloadMetadata(task.Payload)
|
||||
meta := taskPayloadMetadataForDevice(task.Payload, did)
|
||||
overlaysJSON := "[]"
|
||||
if len(meta.Overlays) > 0 {
|
||||
if body, err := json.Marshal(meta.Overlays); err == nil {
|
||||
@ -413,7 +430,7 @@ func (s *TaskService) appendAuditLog(task *models.Task, did string, status model
|
||||
if s == nil || s.auditRepo == nil || task == nil {
|
||||
return
|
||||
}
|
||||
meta := taskPayloadMetadata(task.Payload)
|
||||
meta := taskPayloadMetadataForDevice(task.Payload, did)
|
||||
details := map[string]any{
|
||||
"task_id": task.ID,
|
||||
"type": task.Type,
|
||||
@ -449,13 +466,21 @@ type taskMetadata struct {
|
||||
ConfigVersion string
|
||||
}
|
||||
|
||||
func taskPayloadMetadata(payload any) taskMetadata {
|
||||
func taskPayloadMetadataForDevice(payload any, deviceID string) taskMetadata {
|
||||
var out taskMetadata
|
||||
root, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
configRoot, ok := root["config"].(map[string]any)
|
||||
var configRoot map[string]any
|
||||
if rawConfigs, ok := root["configs"].(map[string]any); ok {
|
||||
if rawConfig, ok := rawConfigs[deviceID].(map[string]any); ok {
|
||||
configRoot = rawConfig
|
||||
}
|
||||
}
|
||||
if configRoot == nil {
|
||||
configRoot, ok = root["config"].(map[string]any)
|
||||
}
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
@ -38,6 +38,29 @@ type VideoSourceRecord struct {
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type DeviceAssignmentRecord struct {
|
||||
DeviceID string
|
||||
ProfileName string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type RecognitionUnitRecord struct {
|
||||
SceneTemplateName string
|
||||
Name string
|
||||
DisplayName string
|
||||
SiteName string
|
||||
VideoSourceRef string
|
||||
OutputChannel string
|
||||
RTSPPort string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type AssetsRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
@ -108,6 +131,44 @@ ON CONFLICT(name) DO UPDATE SET
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) SaveDeviceAssignment(deviceID string, profileName string, description string, bodyJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO device_assignments(device_id, profile_name, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM device_assignments WHERE device_id = ?), ?), ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
profile_name=excluded.profile_name,
|
||||
description=excluded.description,
|
||||
body_json=excluded.body_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, deviceID, profileName, description, bodyJSON, deviceID, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) SaveRecognitionUnit(record RecognitionUnitRecord) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?)
|
||||
ON CONFLICT(scene_template_name, name) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
site_name=excluded.site_name,
|
||||
video_source_ref=excluded.video_source_ref,
|
||||
output_channel=excluded.output_channel,
|
||||
rtsp_port=excluded.rtsp_port,
|
||||
description=excluded.description,
|
||||
body_json=excluded.body_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, record.SceneTemplateName, record.Name, record.DisplayName, record.SiteName, record.VideoSourceRef, record.OutputChannel, record.RTSPPort, record.Description, record.BodyJSON, record.SceneTemplateName, record.Name, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) {
|
||||
return r.listAssets("templates")
|
||||
}
|
||||
@ -170,6 +231,55 @@ ORDER BY updated_at DESC, name ASC
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) ListDeviceAssignments() ([]DeviceAssignmentRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT device_id, profile_name, description, body_json, created_at, updated_at
|
||||
FROM device_assignments
|
||||
ORDER BY updated_at DESC, device_id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []DeviceAssignmentRecord
|
||||
for rows.Next() {
|
||||
var item DeviceAssignmentRecord
|
||||
if err := rows.Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) ListRecognitionUnits() ([]RecognitionUnitRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at
|
||||
FROM recognition_units
|
||||
ORDER BY scene_template_name ASC, name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RecognitionUnitRecord
|
||||
for rows.Next() {
|
||||
var item RecognitionUnitRecord
|
||||
if err := rows.Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) {
|
||||
return r.getAsset("templates", name)
|
||||
}
|
||||
@ -220,10 +330,52 @@ WHERE name = ?
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) GetDeviceAssignment(deviceID string) (*DeviceAssignmentRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var item DeviceAssignmentRecord
|
||||
err := r.db.QueryRow(`
|
||||
SELECT device_id, profile_name, description, body_json, created_at, updated_at
|
||||
FROM device_assignments
|
||||
WHERE device_id = ?
|
||||
`, deviceID).Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) GetRecognitionUnit(sceneTemplateName string, name string) (*RecognitionUnitRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var item RecognitionUnitRecord
|
||||
err := r.db.QueryRow(`
|
||||
SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at
|
||||
FROM recognition_units
|
||||
WHERE scene_template_name = ? AND name = ?
|
||||
`, sceneTemplateName, name).Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteTemplate(name string) error {
|
||||
return r.deleteAsset("templates", name)
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteProfile(name string) error {
|
||||
return r.deleteAsset("profiles", name)
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteIntegrationService(name string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
@ -240,6 +392,26 @@ func (r *AssetsRepo) DeleteVideoSource(name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteDeviceAssignment(deviceID string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(`DELETE FROM device_assignments WHERE device_id = ?`, deviceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteRecognitionUnit(sceneTemplateName string, name string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ? AND name = ?`, sceneTemplateName, name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteOverlay(name string) error {
|
||||
return r.deleteAsset("overlays", name)
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
@ -289,9 +461,9 @@ WHERE name = ?
|
||||
|
||||
rows, err := tx.Query(`
|
||||
SELECT name, description, business_name, body_json
|
||||
FROM profiles
|
||||
FROM scene_templates
|
||||
WHERE primary_template_name = ? OR body_json LIKE ?
|
||||
`, oldName, "%\"template\":\""+oldName+"\"%")
|
||||
`, oldName, "%\"primary_template_name\": \""+oldName+"\"%")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -323,7 +495,7 @@ WHERE primary_template_name = ? OR body_json LIKE ?
|
||||
}
|
||||
for _, item := range updates {
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE profiles
|
||||
UPDATE scene_templates
|
||||
SET primary_template_name = ?, body_json = ?, updated_at = ?
|
||||
WHERE name = ?
|
||||
`, newName, item.bodyJSON, now, item.name); err != nil {
|
||||
@ -331,6 +503,14 @@ WHERE name = ?
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE recognition_units
|
||||
SET body_json = REPLACE(body_json, ?, ?), updated_at = ?
|
||||
WHERE body_json LIKE ?
|
||||
`, `"`+"template"+`": "`+oldName+`"`, `"`+"template"+`": "`+newName+`"`, now, "%\"template\": \""+oldName+"\"%"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -358,22 +538,115 @@ ON CONFLICT(name) DO UPDATE SET
|
||||
`, record.Name, record.Description, record.BodyJSON, record.Name, now, now)
|
||||
return err
|
||||
case "profiles":
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO profiles(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM profiles WHERE name = ?), ?), ?)
|
||||
normalized, units, replaceUnits, err := normalizeProfileRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM scene_templates WHERE name = ?), ?), ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
primary_template_name=excluded.primary_template_name,
|
||||
business_name=excluded.business_name,
|
||||
description=excluded.description,
|
||||
body_json=excluded.body_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, record.Name, record.TemplateName, record.BusinessName, record.Description, record.BodyJSON, record.Name, now, now)
|
||||
return err
|
||||
`, normalized.Name, normalized.TemplateName, normalized.BusinessName, normalized.Description, normalized.BodyJSON, normalized.Name, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if replaceUnits {
|
||||
if _, err := tx.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, unit := range units {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?)
|
||||
ON CONFLICT(scene_template_name, name) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
site_name=excluded.site_name,
|
||||
video_source_ref=excluded.video_source_ref,
|
||||
output_channel=excluded.output_channel,
|
||||
rtsp_port=excluded.rtsp_port,
|
||||
description=excluded.description,
|
||||
body_json=excluded.body_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, unit.SceneTemplateName, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, unit.Description, unit.BodyJSON, unit.SceneTemplateName, unit.Name, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProfileRecord(record AssetRecord) (AssetRecord, []RecognitionUnitRecord, bool, error) {
|
||||
raw := map[string]any{}
|
||||
if strings.TrimSpace(record.BodyJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
|
||||
return AssetRecord{}, nil, false, err
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
_, hasInstances := raw["instances"]
|
||||
if !hasInstances {
|
||||
return record, nil, false, nil
|
||||
}
|
||||
templateDoc, units, err := splitLegacyProfileDocument(struct {
|
||||
Name string
|
||||
TemplateName string
|
||||
BusinessName string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}{
|
||||
Name: record.Name,
|
||||
TemplateName: record.TemplateName,
|
||||
BusinessName: record.BusinessName,
|
||||
Description: record.Description,
|
||||
BodyJSON: record.BodyJSON,
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return AssetRecord{}, nil, false, err
|
||||
}
|
||||
body, err := json.MarshalIndent(templateDoc, "", " ")
|
||||
if err != nil {
|
||||
return AssetRecord{}, nil, false, err
|
||||
}
|
||||
normalized := record
|
||||
normalized.BodyJSON = string(append(body, '\n'))
|
||||
outUnits := make([]RecognitionUnitRecord, 0, len(units))
|
||||
for _, unit := range units {
|
||||
outUnits = append(outUnits, RecognitionUnitRecord{
|
||||
SceneTemplateName: record.Name,
|
||||
Name: unit.Name,
|
||||
DisplayName: unit.DisplayName,
|
||||
SiteName: unit.SiteName,
|
||||
VideoSourceRef: unit.VideoSourceRef,
|
||||
OutputChannel: unit.OutputChannel,
|
||||
RTSPPort: unit.RTSPPort,
|
||||
Description: record.Description,
|
||||
BodyJSON: unit.BodyJSON,
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return normalized, outUnits, true, nil
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) listAssets(table string) ([]AssetRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
@ -386,7 +659,7 @@ ORDER BY updated_at DESC, name ASC
|
||||
if table == "profiles" {
|
||||
query = `
|
||||
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
|
||||
FROM profiles
|
||||
FROM scene_templates
|
||||
ORDER BY updated_at DESC, name ASC
|
||||
`
|
||||
}
|
||||
@ -419,7 +692,7 @@ WHERE name = ?
|
||||
if table == "profiles" {
|
||||
query = `
|
||||
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
|
||||
FROM profiles
|
||||
FROM scene_templates
|
||||
WHERE name = ?
|
||||
`
|
||||
}
|
||||
@ -439,6 +712,9 @@ func (r *AssetsRepo) deleteAsset(table string, name string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
if table == "profiles" {
|
||||
table = "scene_templates"
|
||||
}
|
||||
_, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name)
|
||||
return err
|
||||
}
|
||||
@ -449,6 +725,10 @@ func rewriteProfileTemplateRefs(bodyJSON string, oldName string, newName string)
|
||||
return "", false, err
|
||||
}
|
||||
changed := false
|
||||
if strings.TrimSpace(valueString(raw["primary_template_name"])) == oldName {
|
||||
raw["primary_template_name"] = newName
|
||||
changed = true
|
||||
}
|
||||
instances, _ := raw["instances"].([]any)
|
||||
for _, item := range instances {
|
||||
instanceMap, _ := item.(map[string]any)
|
||||
|
||||
@ -78,7 +78,7 @@ func TestAssetsRepoRenameTemplateUpdatesProfileReferences(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile: %v", err)
|
||||
}
|
||||
if profile == nil || profile.TemplateName != "helmet_v2" || !strings.Contains(profile.BodyJSON, `"template": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"template":"helmet_v2"`) {
|
||||
if profile == nil || profile.TemplateName != "helmet_v2" || !strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`) {
|
||||
t.Fatalf("expected profile template ref updated, got %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -74,3 +75,32 @@ WHERE device_id = ?
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *DeviceConfigStateRepo) ListByProfileName(profileName string) ([]DeviceConfigStateRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT device_id, template_name, profile_name, overlays_json, config_id, config_version, last_applied_task_id, updated_at
|
||||
FROM device_config_state
|
||||
WHERE profile_name = ?
|
||||
ORDER BY device_id ASC
|
||||
`, profileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []DeviceConfigStateRecord
|
||||
for rows.Next() {
|
||||
var item DeviceConfigStateRecord
|
||||
if err := rows.Scan(&item.DeviceID, &item.TemplateName, &item.ProfileName, &item.OverlaysJSON, &item.ConfigID, &item.ConfigVersion, &item.LastAppliedTaskID, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@ package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const schema001 = `
|
||||
@ -52,6 +55,39 @@ CREATE TABLE IF NOT EXISTS video_sources (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS scene_templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
primary_template_name TEXT NOT NULL DEFAULT '',
|
||||
business_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS recognition_units (
|
||||
id INTEGER PRIMARY KEY,
|
||||
scene_template_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
site_name TEXT NOT NULL DEFAULT '',
|
||||
video_source_ref TEXT NOT NULL DEFAULT '',
|
||||
output_channel TEXT NOT NULL DEFAULT '',
|
||||
rtsp_port TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(scene_template_name, name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS device_assignments (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
profile_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
@ -109,7 +145,10 @@ func migrate(db *sql.DB) error {
|
||||
if _, err := db.Exec(schema001); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateProfilePrimaryTemplateName(db)
|
||||
if err := migrateProfilePrimaryTemplateName(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateProfilesToSceneTemplates(db)
|
||||
}
|
||||
|
||||
func migrateProfilePrimaryTemplateName(db *sql.DB) error {
|
||||
@ -185,3 +224,204 @@ func hasColumn(db *sql.DB, table string, column string) (bool, error) {
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
func migrateProfilesToSceneTemplates(db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(1) FROM scene_templates`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
SELECT name, primary_template_name, business_name, description, body_json, created_at, updated_at
|
||||
FROM profiles
|
||||
ORDER BY created_at ASC, name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type legacyProfile struct {
|
||||
Name string
|
||||
TemplateName string
|
||||
BusinessName string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
legacy := make([]legacyProfile, 0)
|
||||
for rows.Next() {
|
||||
var item legacyProfile
|
||||
if err := rows.Scan(&item.Name, &item.TemplateName, &item.BusinessName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(legacy) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for _, item := range legacy {
|
||||
templateDoc, units, err := splitLegacyProfileDocument(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templateBody, err := json.MarshalIndent(templateDoc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdAt := firstNonEmpty(item.CreatedAt, now)
|
||||
updatedAt := firstNonEmpty(item.UpdatedAt, createdAt)
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO NOTHING
|
||||
`, item.Name, item.TemplateName, item.BusinessName, item.Description, string(append(templateBody, '\n')), createdAt, updatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, unit := range units {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scene_template_name, name) DO NOTHING
|
||||
`, item.Name, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, item.Description, unit.BodyJSON, createdAt, updatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type migratedRecognitionUnit struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
SiteName string
|
||||
VideoSourceRef string
|
||||
OutputChannel string
|
||||
RTSPPort string
|
||||
BodyJSON string
|
||||
}
|
||||
|
||||
func splitLegacyProfileDocument(item struct {
|
||||
Name string
|
||||
TemplateName string
|
||||
BusinessName string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}) (map[string]any, []migratedRecognitionUnit, error) {
|
||||
raw := map[string]any{}
|
||||
if strings.TrimSpace(item.BodyJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(item.BodyJSON), &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
raw["name"] = item.Name
|
||||
if strings.TrimSpace(item.TemplateName) != "" {
|
||||
raw["primary_template_name"] = item.TemplateName
|
||||
}
|
||||
if strings.TrimSpace(item.BusinessName) != "" {
|
||||
raw["business_name"] = item.BusinessName
|
||||
}
|
||||
if strings.TrimSpace(item.Description) != "" {
|
||||
raw["description"] = item.Description
|
||||
}
|
||||
var units []migratedRecognitionUnit
|
||||
if instances, ok := raw["instances"].([]any); ok {
|
||||
for _, entry := range instances {
|
||||
inst, _ := entry.(map[string]any)
|
||||
if inst == nil {
|
||||
continue
|
||||
}
|
||||
unitName := strings.TrimSpace(stringAny(inst["name"]))
|
||||
if unitName == "" {
|
||||
continue
|
||||
}
|
||||
sceneMeta, _ := inst["scene_meta"].(map[string]any)
|
||||
inputBindings, _ := inst["input_bindings"].(map[string]any)
|
||||
outputBindings, _ := inst["output_bindings"].(map[string]any)
|
||||
videoSourceRef := strings.TrimSpace(bindingStringFromAny(inputBindings, "video_input_main", "video_source_ref"))
|
||||
outputChannel := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "channel_no"))
|
||||
if outputChannel == "" {
|
||||
outputChannel = unitName
|
||||
}
|
||||
rtspPort := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "publish_rtsp_port"))
|
||||
if rtspPort == "" {
|
||||
rtspPort = "8555"
|
||||
}
|
||||
unitRaw := cloneAnyMap(inst)
|
||||
body, err := json.MarshalIndent(unitRaw, "", " ")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
units = append(units, migratedRecognitionUnit{
|
||||
Name: unitName,
|
||||
DisplayName: strings.TrimSpace(stringAny(sceneMeta["display_name"])),
|
||||
SiteName: strings.TrimSpace(stringAny(sceneMeta["site_name"])),
|
||||
VideoSourceRef: videoSourceRef,
|
||||
OutputChannel: outputChannel,
|
||||
RTSPPort: rtspPort,
|
||||
BodyJSON: string(append(body, '\n')),
|
||||
})
|
||||
}
|
||||
}
|
||||
delete(raw, "instances")
|
||||
return raw, units, nil
|
||||
}
|
||||
|
||||
func bindingStringFromAny(bindings map[string]any, slot string, field string) string {
|
||||
entry, _ := bindings[slot].(map[string]any)
|
||||
return strings.TrimSpace(stringAny(entry[field]))
|
||||
}
|
||||
|
||||
func stringAny(v any) string {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", vv)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAnyMap(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 firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,9 @@
|
||||
--danger-soft:#242424;
|
||||
--danger-soft-hover:#303030;
|
||||
--danger-soft-text:#dddddd;
|
||||
--assignment-low-bg:#24190d;
|
||||
--assignment-busy-bg:#3a2812;
|
||||
--assignment-full-bg:#4b3110;
|
||||
--teal:#dddddd;
|
||||
--green:#66c98f;
|
||||
--amber:#d8a657;
|
||||
@ -61,6 +64,9 @@ body[data-theme="blue-light"]{
|
||||
--danger-soft:#f0dada;
|
||||
--danger-soft-hover:#e8caca;
|
||||
--danger-soft-text:#a43f3f;
|
||||
--assignment-low-bg:#f4ead8;
|
||||
--assignment-busy-bg:#f0dcc0;
|
||||
--assignment-full-bg:#eccb96;
|
||||
--teal:#2d6fb5;
|
||||
--green:#245f9f;
|
||||
--amber:#9b650e;
|
||||
@ -95,6 +101,9 @@ body[data-theme="graphite-gold"]{
|
||||
--danger-soft:#301d1c;
|
||||
--danger-soft-hover:#3b2423;
|
||||
--danger-soft-text:#d66a63;
|
||||
--assignment-low-bg:#332514;
|
||||
--assignment-busy-bg:#3f3015;
|
||||
--assignment-full-bg:#57401a;
|
||||
--teal:#d3a84f;
|
||||
--green:#f0cf7a;
|
||||
--amber:#d79a3b;
|
||||
@ -196,7 +205,18 @@ th{background:var(--surface-soft);font-size:12px;font-weight:600;color:var(--mut
|
||||
.table-wrap td a{color:var(--table-link)}
|
||||
.table-wrap td a:hover{color:var(--text)}
|
||||
tbody tr:hover{background:var(--surface-soft)}
|
||||
tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary);outline-offset:-1px}
|
||||
tbody tr.selected{background:var(--selected-row)}
|
||||
tbody tr.selected td{color:var(--text)}
|
||||
tbody tr.selected .mono,
|
||||
tbody tr.selected a,
|
||||
tbody tr.selected strong{color:var(--text)}
|
||||
.checkbox-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
|
||||
.checkbox-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
|
||||
.checkbox-card input{margin:0 0 4px}
|
||||
.checkbox-card-title{font-size:13px}
|
||||
.checkbox-card-meta{font-size:12px;color:var(--muted)}
|
||||
tbody tr[data-profile-row],
|
||||
tbody tr[data-nav-row]{cursor:pointer}
|
||||
|
||||
.device-cell{display:flex;align-items:center;gap:12px;min-width:220px}
|
||||
.device-avatar{width:32px;height:32px;border-radius:var(--radius);background:var(--surface-strong);color:var(--primary);display:grid;place-items:center}
|
||||
@ -266,6 +286,20 @@ tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary
|
||||
.info-list>div{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
|
||||
.info-list span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)}
|
||||
.info-list strong{display:block;font-size:13px;font-weight:400;line-height:1.45}
|
||||
.detail-sheet{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
.detail-item{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
|
||||
.detail-item.full{grid-column:1/-1}
|
||||
.detail-item span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)}
|
||||
.detail-item strong{display:block;font-size:13px;font-weight:400;line-height:1.45;color:var(--text)}
|
||||
.form-state-hint{margin-top:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.editor-state.readonly .section-title{padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
.editor-state.editing{border-color:var(--border-strong);box-shadow:0 0 0 1px rgba(255,255,255,.03),var(--shadow)}
|
||||
.editor-state.editing .section-title{padding-bottom:10px;border-bottom:1px solid var(--border-strong)}
|
||||
.editor-state.editing .field-grid label>span{color:var(--text)}
|
||||
.editor-state.editing .field-grid input,
|
||||
.editor-state.editing .field-grid select,
|
||||
.editor-state.editing .field-grid textarea{border-color:var(--border-strong);background:var(--surface)}
|
||||
.editor-state.editing .profile-instance-grid{margin-top:16px}
|
||||
.editable-line{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.editable-line strong{margin:0;flex:1 1 auto}
|
||||
.icon-only{display:inline-flex;align-items:center;justify-content:center;padding:0;min-width:28px;width:28px;height:28px}
|
||||
@ -329,6 +363,45 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
|
||||
.select-cell{width:52px;text-align:center;vertical-align:middle}
|
||||
.select-cell input[type=checkbox]{width:16px;height:16px;margin:0;accent-color:var(--primary)}
|
||||
|
||||
.assignment-board-page .section-title{margin-bottom:10px}
|
||||
.assignment-kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}
|
||||
.assignment-kpi{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
|
||||
.assignment-kpi span{display:block;font-size:12px;color:var(--muted)}
|
||||
.assignment-kpi strong{display:block;margin-top:6px;font-size:18px;font-weight:400;color:var(--text)}
|
||||
.assignment-action-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}
|
||||
.assignment-slider{display:flex;align-items:center;gap:10px;min-width:260px}
|
||||
.assignment-slider span{font-size:12px;color:var(--muted)}
|
||||
.assignment-slider strong{font-size:12px;font-weight:500;color:var(--text)}
|
||||
.assignment-slider input[type=range]{width:180px}
|
||||
.assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px}
|
||||
.assignment-device-card{padding:10px 11px;border:1px solid var(--border);border-radius:var(--radius);background:var(--assignment-low-bg)}
|
||||
.assignment-device-card.state-idle{background:var(--surface-soft)}
|
||||
.assignment-device-card.state-low{background:var(--assignment-low-bg)}
|
||||
.assignment-device-card.state-busy{background:var(--assignment-busy-bg)}
|
||||
.assignment-device-card.state-full{background:var(--assignment-full-bg)}
|
||||
.assignment-device-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:8px}
|
||||
.assignment-device-head h3{margin:0;font-size:13px;font-weight:500;color:var(--text)}
|
||||
.assignment-device-head .mono{display:block;margin-top:4px;font-size:11px;color:var(--muted)}
|
||||
.assignment-device-metrics{display:flex;align-items:center;gap:8px}
|
||||
.assignment-device-metrics strong{font-size:12px;font-weight:500;color:var(--text)}
|
||||
.assignment-chip-list{display:flex;flex-wrap:wrap;gap:6px;min-height:24px}
|
||||
.assignment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
|
||||
.assignment-chip-unassigned{background:var(--surface-strong)}
|
||||
.assignment-chip-text{font-size:11px;color:var(--text)}
|
||||
.assignment-chip-remove{border:0;background:transparent;color:var(--muted);padding:0;line-height:1;cursor:pointer}
|
||||
.assignment-chip-remove:hover{color:var(--text)}
|
||||
.assignment-device-add{display:flex;gap:6px;align-items:center;margin-top:10px}
|
||||
.assignment-device-add select{flex:1 1 auto;padding:7px 8px;border:1px solid var(--border);border-radius:var(--radius);background:var(--input-bg);color:var(--text);font:inherit}
|
||||
.assignment-unassigned{margin-top:16px;padding-top:4px}
|
||||
|
||||
@media (max-width:1400px){
|
||||
.assignment-board-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
}
|
||||
|
||||
@media (max-width:900px){
|
||||
.assignment-board-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
}
|
||||
|
||||
@media (max-width:1024px){
|
||||
.app-shell{grid-template-columns:1fr}
|
||||
.sidebar{position:relative;height:auto}
|
||||
@ -338,4 +411,6 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
|
||||
.stats,.detail-grid,.device-selector-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr}
|
||||
.hero-band{flex-direction:column;align-items:flex-start}
|
||||
.batch-toolbar{flex-direction:column}
|
||||
.assignment-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.assignment-board-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
@ -5,6 +5,15 @@
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/assets/overlays?new=1">{{icon "apply"}}<span>新增调试参数</span></a>
|
||||
{{if .AssetOverlay}}
|
||||
<a class="btn secondary" href="/ui/assets/overlays?name={{.AssetOverlay.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/delete">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
@ -17,7 +26,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetOverlays}}
|
||||
<tr>
|
||||
<tr data-nav-row data-nav-href="/ui/assets/overlays?name={{.Name}}"{{if and $.AssetOverlay (eq $.AssetOverlay.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/assets/overlays?name={{.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>
|
||||
@ -31,10 +40,20 @@
|
||||
</div>
|
||||
|
||||
{{if .AssetOverlay}}
|
||||
<div class="card">
|
||||
<form method="post" action="/ui/assets/overlays">
|
||||
<div class="card editor-state {{if .AssetOverlayEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数详情</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>{{if .AssetOverlayEditing}}调试参数编辑{{else}}调试参数详情{{end}}{{if .AssetOverlay.Name}} · {{.AssetOverlay.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetOverlayEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>当前调试参数正在编辑,保存后生效。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前调试参数为只读展示。</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button
|
||||
@ -42,23 +61,39 @@
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/assets/overlays/{{.AssetOverlay.Name}}/export"
|
||||
data-default-filename="{{.AssetOverlay.Name}}.json"
|
||||
>{{icon "apply"}}<span>另存为 JSON</span></button>
|
||||
>{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>调试参数</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>
|
||||
{{if .AssetOverlayEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>调试参数名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetOverlay.Name}}" autofocus /></label>
|
||||
<label><span>目标数量</span><input value="{{.AssetOverlay.OverrideTargetNum}}" readonly /></label>
|
||||
<label class="full"><span>描述</span><input name="description" value="{{.AssetOverlay.Description}}" /></label>
|
||||
<label class="full"><span>调试参数 JSON<span class="required-mark">*</span></span><textarea class="code-input" name="json">{{.OverlayDraftJSON}}</textarea></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/overlays{{if .AssetOverlay.Name}}?name={{.AssetOverlay.Name}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
|
||||
<div class="detail-item"><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
|
||||
<div class="detail-item full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item full"><span>路径</span><strong class="mono">{{.AssetOverlay.Path}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if not .AssetOverlayEditing}}
|
||||
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
|
||||
<pre>{{json .AssetOverlay.Raw}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{template "asset_tabs_end" .}}
|
||||
{{end}}
|
||||
|
||||
@ -4,6 +4,15 @@
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/plans?new=1">{{icon "apply"}}<span>新建场景</span></a>
|
||||
{{if .SelectedProfile}}
|
||||
<a class="btn secondary" href="/ui/plans?name={{.SelectedProfile}}&edit=1">编辑</a>
|
||||
<form method="post" action="/ui/plans/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景配置吗?');">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
@ -12,19 +21,17 @@
|
||||
<th>场景配置</th>
|
||||
<th>描述</th>
|
||||
<th>视频通道</th>
|
||||
<th>队列</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetProfiles}}
|
||||
<tr>
|
||||
<tr data-profile-row data-profile-href="/ui/plans?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/plans?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td>{{len .Instances}}</td>
|
||||
<td class="mono">{{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景配置</div></div></td></tr>
|
||||
<tr><td colspan="3"><div class="empty-state compact"><div class="empty-title">还没有场景配置</div></div></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -32,32 +39,62 @@
|
||||
</div>
|
||||
|
||||
{{if .AssetProfileEditor}}
|
||||
<form method="post" action="/ui/plans/{{.AssetProfileEditor.Name}}">
|
||||
{{if .AssetProfileEditing}}
|
||||
<form method="post" action="{{.AssetProfileFormAction}}">
|
||||
<input type="hidden" id="active-instance-input" name="active_instance" value="{{.ActiveInstanceIndex}}" />
|
||||
<div class="card">
|
||||
{{end}}
|
||||
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetProfileEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>正在编辑场景配置</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
{{if .AssetProfileEditing}}
|
||||
<button type="submit">{{icon "apply"}}<span>保存场景配置</span></button>
|
||||
<a class="btn secondary" href="/ui/plans?name={{.AssetProfileEditor.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
<a class="btn secondary" href="/ui/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
{{end}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/plans/{{.AssetProfileEditor.Name}}/export"
|
||||
data-default-filename="{{.AssetProfileEditor.Name}}.json"
|
||||
>{{icon "apply"}}<span>另存为 JSON</span></button>
|
||||
>{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetProfileEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>场景名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
|
||||
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
|
||||
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
|
||||
<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><span>场景名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if .AssetProfileEditing}}{{if not .SelectedProfile}}autofocus{{end}}{{else}}readonly{{end}} /></label>
|
||||
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
|
||||
<label>
|
||||
<span>调试参数</span>
|
||||
<select name="overlay_name" {{if not .AssetProfileEditing}}disabled{{end}}>
|
||||
<option value="">不使用</option>
|
||||
{{range .AssetOverlays}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.OverlayName .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>场景名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>业务名称</span><strong>{{if .AssetProfileEditor.BusinessName}}{{.AssetProfileEditor.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>调试参数</span><strong>{{if .AssetProfileEditor.OverlayName}}{{.AssetProfileEditor.OverlayName}}{{else}}不使用{{end}}</strong></div>
|
||||
<div class="detail-item"><span>站点名</span><strong>{{if .AssetProfileEditor.SiteName}}{{.AssetProfileEditor.SiteName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@ -67,7 +104,9 @@
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<span class="pill">{{len .AssetProfileEditor.Instances}} 路</span>
|
||||
{{if .AssetProfileEditing}}
|
||||
<button class="btn secondary" type="submit" name="add_instance" value="1">{{icon "apply"}}<span>新增通道</span></button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
@ -132,7 +171,9 @@
|
||||
<td>
|
||||
<div class="actions compact">
|
||||
<button type="button" class="btn ghost js-open-instance-editor" data-instance-index="{{$i}}" data-target="profile-instance-{{$i}}">编辑</button>
|
||||
{{if $.AssetProfileEditing}}
|
||||
<button class="btn secondary" type="submit" name="remove_instance" value="{{$i}}">删除</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -142,25 +183,34 @@
|
||||
</div>
|
||||
|
||||
{{if .AssetProfileEditor.Instances}}
|
||||
<div class="card">
|
||||
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>通道详情</span></h2>
|
||||
<div class="form-hint">当前通道的修改会在点击“保存场景配置”后统一生效。</div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>通道详情{{if gt (len .AssetProfileEditor.Instances) 0}}{{with index .AssetProfileEditor.Instances .ActiveInstanceIndex}}{{if .Name}} · {{.Name}}{{end}}{{end}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetProfileEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>当前通道的修改会在点击“保存场景配置”后统一生效。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前通道详情为只读展示。</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{range $i, $inst := .AssetProfileEditor.Instances}}
|
||||
{{$template := index $.AssetTemplateMap $inst.Template}}
|
||||
<section id="profile-instance-{{$i}}" class="profile-instance-editor" data-instance-editor="{{$i}}" {{if ne $.ActiveInstanceIndex $i}}hidden{{end}}>
|
||||
{{if $.AssetProfileEditing}}
|
||||
<div class="field-grid profile-instance-grid">
|
||||
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
|
||||
<label><span>通道名<span class="required-mark">*</span></span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" /></label>
|
||||
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" /></label>
|
||||
<label><span>通道名<span class="required-mark">*</span></span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" {{if not $.AssetProfileEditing}}readonly{{end}} /></label>
|
||||
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" {{if not $.AssetProfileEditing}}readonly{{end}} /></label>
|
||||
<label><span>模板</span><input class="mono" value="{{$inst.Template}}" readonly /></label>
|
||||
{{range $slot := $template.Slots.Inputs}}
|
||||
<label>
|
||||
<span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span>
|
||||
<select name="instances[{{$i}}].input_bindings.{{$slot.Name}}.video_source_ref">
|
||||
<select name="instances[{{$i}}].input_bindings.{{$slot.Name}}.video_source_ref" {{if not $.AssetProfileEditing}}disabled{{end}}>
|
||||
<option value="">未选择</option>
|
||||
{{range $.AssetVideoSources}}
|
||||
<option value="{{.Name}}" {{if eq (inputBindingRef $inst.InputBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Area}} - {{.Area}}{{end}}</option>
|
||||
@ -171,7 +221,7 @@
|
||||
{{range $slot := $template.Slots.Services}}
|
||||
<label>
|
||||
<span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span>
|
||||
<select name="instances[{{$i}}].service_bindings.{{$slot.Name}}.service_ref">
|
||||
<select name="instances[{{$i}}].service_bindings.{{$slot.Name}}.service_ref" {{if not $.AssetProfileEditing}}disabled{{end}}>
|
||||
<option value="">未选择</option>
|
||||
{{range $.AssetIntegrations}}{{if eq .Type $slot.Type}}
|
||||
<option value="{{.Name}}" {{if eq (serviceBindingRef $inst.ServiceBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
@ -180,21 +230,44 @@
|
||||
</label>
|
||||
{{end}}
|
||||
{{range $slot := $template.Slots.Outputs}}
|
||||
<label><span>{{$slot.Description}} HLS 路径</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_hls_path" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_hls_path"}}" /></label>
|
||||
<label><span>{{$slot.Description}} RTSP 路径</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_path" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_path"}}" /></label>
|
||||
<label><span>{{$slot.Description}} RTSP 端口</span><input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_port" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}" /></label>
|
||||
<label><span>{{$slot.Description}} 通道号</span><input name="instances[{{$i}}].output_bindings.{{$slot.Name}}.channel_no" value="{{outputBindingValue $inst.OutputBindings $slot.Name "channel_no"}}" /></label>
|
||||
<label>
|
||||
<span>{{$slot.Description}} RTSP 端口</span>
|
||||
<input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_port" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}" {{if not $.AssetProfileEditing}}readonly{{end}} />
|
||||
</label>
|
||||
{{end}}
|
||||
<details style="grid-column:1/-1;margin-top:0.25rem">
|
||||
<summary style="cursor:pointer;font-size:0.875rem;font-weight:500;color:var(--text-secondary)">高级设置 JSON</summary>
|
||||
<textarea name="instances[{{$i}}].advanced_params" rows="5" class="code-input" style="margin-top:0.5rem;width:100%;box-sizing:border-box">{{if $inst.AdvancedParams}}{{json $inst.AdvancedParams}}{{end}}</textarea>
|
||||
</details>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-grid profile-instance-grid detail-sheet">
|
||||
<div class="detail-item"><span>通道名</span><strong class="mono">{{$inst.Name}}</strong></div>
|
||||
<div class="detail-item"><span>通道显示名</span><strong>{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>模板</span><strong class="mono">{{$inst.Template}}</strong></div>
|
||||
{{range $slot := $template.Slots.Inputs}}
|
||||
<div class="detail-item">
|
||||
<span>{{$slot.Description}}</span>
|
||||
<strong class="mono">{{if inputBindingRef $inst.InputBindings $slot.Name}}{{inputBindingRef $inst.InputBindings $slot.Name}}{{else}}-{{end}}</strong>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range $slot := $template.Slots.Services}}
|
||||
<div class="detail-item">
|
||||
<span>{{$slot.Description}}</span>
|
||||
<strong>{{if serviceBindingRef $inst.ServiceBindings $slot.Name}}{{serviceBindingRef $inst.ServiceBindings $slot.Name}}{{else}}-{{end}}</strong>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range $slot := $template.Slots.Outputs}}
|
||||
<div class="detail-item">
|
||||
<span>{{$slot.Description}} RTSP 端口</span>
|
||||
<strong class="mono">{{if outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}{{else}}-{{end}}</strong>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .AssetProfileEditing}}
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
|
||||
|
||||
@ -4,10 +4,21 @@
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>识别模板列表</span></h2>
|
||||
<div class="form-hint">标准模板应作为基线,复制后再做定制化修改。空白模板仅用于高级场景。</div>
|
||||
<div class="form-hint">标准模板应作为基线,复制后再做定制化修改。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn ghost" href="/ui/assets/templates?mode=blank">{{icon "add"}}<span>新建空白模板</span></a>
|
||||
{{if .AssetTemplate}}
|
||||
{{if .AssetTemplate.ReadOnly}}
|
||||
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/clone">
|
||||
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准模板</span></button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a class="btn secondary" href="/ui/assets/templates?name={{.AssetTemplate.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/delete" onsubmit="return confirm('确认删除这个用户模板吗?');">
|
||||
<button type="submit" class="btn secondary">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@ -22,7 +33,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetTemplates}}
|
||||
<tr>
|
||||
<tr data-nav-row data-nav-href="/ui/assets/templates?name={{.Name}}"{{if and $.AssetTemplate (eq $.AssetTemplate.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/assets/templates?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td>{{.NodeCount}}节点 / {{.EdgeCount}}连线</td>
|
||||
@ -36,70 +47,57 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .TemplateCreateMode}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>{{if eq .TemplateCreateMode "clone"}}复制标准模板{{else}}新建空白模板{{end}}</span></h2>
|
||||
<div class="form-hint">
|
||||
{{if eq .TemplateCreateMode "clone"}}复制后会保留原有节点和连线结构,再进入可视化编辑。{{else}}空白模板会打开空白画布,不包含任何预置处理链。{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/ui/assets/templates/create" class="inline-form">
|
||||
<input type="hidden" name="clone_source" value="{{.TemplateCloneSource}}" />
|
||||
<label>
|
||||
<span>模板名称</span>
|
||||
<input name="name" value="{{.TemplateDraftName}}" placeholder="例如 std_workshop_face_recognition_shoe_alarm_copy" />
|
||||
</label>
|
||||
<label class="grow">
|
||||
<span>模板描述</span>
|
||||
<input name="description" value="{{.TemplateDraftDescription}}" placeholder="说明这个模板用于什么业务流程" />
|
||||
</label>
|
||||
<button type="submit" class="btn secondary">{{icon "apply"}}<span>{{if eq .TemplateCreateMode "clone"}}复制并编辑{{else}}创建并编辑{{end}}</span></button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AssetTemplate}}
|
||||
<div class="card">
|
||||
<div class="card editor-state {{if .AssetTemplateEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>模板详情</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>{{if .AssetTemplateEditing}}模板编辑{{else}}模板详情{{end}}{{if .AssetTemplate.Name}} · {{.AssetTemplate.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetTemplateEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>当前模板基础信息正在编辑,结构修改请进入可视化编辑。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前模板为只读详情展示。</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else}}可视化编辑{{end}}</span></a>
|
||||
<a class="btn ghost" href="/ui/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>{{if .AssetTemplate.ReadOnly}}复制标准模板{{else}}复制模板{{end}}</span></a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export"
|
||||
data-default-filename="{{.AssetTemplate.Name}}.json"
|
||||
>{{icon "apply"}}<span>另存为 JSON</span></button>
|
||||
{{if not .AssetTemplate.ReadOnly}}
|
||||
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/delete" onsubmit="return confirm('确认删除这个用户模板吗?');">
|
||||
<button type="submit" class="btn danger">{{icon "close"}}<span>删除模板</span></button>
|
||||
</form>
|
||||
{{end}}
|
||||
>{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetTemplateEditing}}
|
||||
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename">
|
||||
<div class="field-grid">
|
||||
<label><span>模板名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetTemplate.Name}}" class="mono" autofocus /></label>
|
||||
<label><span>模板类型</span><input value="{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}" readonly /></label>
|
||||
<label class="full"><span>模板描述</span><input name="description" value="{{.AssetTemplate.Description}}" /></label>
|
||||
</div>
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
|
||||
<div class="detail-item"><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
|
||||
<div class="detail-item"><span>输入槽位</span><strong>{{if .AssetTemplate.Slots.Inputs}}{{len .AssetTemplate.Slots.Inputs}}{{else}}0{{end}}</strong></div>
|
||||
<div class="detail-item"><span>服务槽位</span><strong>{{if .AssetTemplate.Slots.Services}}{{len .AssetTemplate.Slots.Services}}{{else}}0{{end}}</strong></div>
|
||||
<div class="detail-item"><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div>
|
||||
<div class="detail-item full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/templates?name={{.AssetTemplate.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="info-list">
|
||||
<div>
|
||||
<span>模板名</span>
|
||||
{{if .AssetTemplate.ReadOnly}}
|
||||
<strong class="mono">{{.AssetTemplate.Name}}</strong>
|
||||
{{else}}
|
||||
<div class="editable-line">
|
||||
<strong class="mono js-inline-edit-display">{{.AssetTemplate.Name}}</strong>
|
||||
<button type="button" class="btn ghost icon-only js-inline-edit-toggle" data-target="template-name-edit" aria-label="修改模板名" title="修改模板名">{{icon "edit"}}</button>
|
||||
</div>
|
||||
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename" id="template-name-edit" class="inline-edit-form" hidden>
|
||||
<input type="hidden" name="description" value="{{.AssetTemplate.Description}}" />
|
||||
<input name="name" value="{{.AssetTemplate.Name}}" class="mono" />
|
||||
<button type="submit" class="btn secondary icon-only" aria-label="保存模板名" title="保存模板名">{{icon "apply"}}</button>
|
||||
<button type="button" class="btn ghost icon-only js-inline-edit-cancel" data-target="template-name-edit" aria-label="取消" title="取消">{{icon "close"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
<div><span>模板类型</span><strong>{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}</strong></div>
|
||||
<div><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
|
||||
@ -110,23 +108,11 @@
|
||||
<div><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div>
|
||||
<div class="full">
|
||||
<span>描述</span>
|
||||
{{if .AssetTemplate.ReadOnly}}
|
||||
<strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong>
|
||||
{{else}}
|
||||
<div class="editable-line">
|
||||
<strong class="js-inline-edit-display">{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong>
|
||||
<button type="button" class="btn ghost icon-only js-inline-edit-toggle" data-target="template-description-edit" aria-label="修改描述" title="修改描述">{{icon "edit"}}</button>
|
||||
</div>
|
||||
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename" id="template-description-edit" class="inline-edit-form" hidden>
|
||||
<input type="hidden" name="name" value="{{.AssetTemplate.Name}}" />
|
||||
<input name="description" value="{{.AssetTemplate.Description}}" />
|
||||
<button type="submit" class="btn secondary icon-only" aria-label="保存描述" title="保存描述">{{icon "apply"}}</button>
|
||||
<button type="button" class="btn ghost icon-only js-inline-edit-cancel" data-target="template-description-edit" aria-label="取消" title="取消">{{icon "close"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs}}
|
||||
|
||||
@ -61,7 +61,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetIntegrations}}
|
||||
<tr>
|
||||
<tr data-nav-row data-nav-href="/ui/assets/integrations?name={{.Name}}"{{if and $.AssetIntegration (eq $.AssetIntegration.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/assets/integrations?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{.TypeLabel}}</td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
@ -78,50 +78,77 @@
|
||||
</div>
|
||||
{{if .AssetIntegration}}
|
||||
<form method="post" action="/ui/assets/integrations">
|
||||
<div class="card">
|
||||
<div class="card editor-state {{if .AssetIntegrationEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}{{if .AssetIntegration.Name}} · {{.AssetIntegration.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetIntegrationEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>当前第三方服务正在编辑,保存后生效。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前第三方服务为只读展示。</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetIntegrationEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>服务名称</span><input name="name" value="{{.AssetIntegration.Name}}" {{if .AssetIntegrationEditing}}autofocus{{else}}readonly{{end}} /></label>
|
||||
<label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" autofocus /></label>
|
||||
<label>
|
||||
<span>服务类型</span>
|
||||
<select name="type" {{if not .AssetIntegrationEditing}}disabled{{end}}>
|
||||
<span>服务类型<span class="required-mark">*</span></span>
|
||||
<select name="type">
|
||||
<option value="object_storage" {{if eq .AssetIntegration.Type "object_storage"}}selected{{end}}>对象存储</option>
|
||||
<option value="token_service" {{if eq .AssetIntegration.Type "token_service"}}selected{{end}}>认证服务</option>
|
||||
<option value="alarm_service" {{if eq .AssetIntegration.Type "alarm_service"}}selected{{end}}>告警服务</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetIntegration.Description}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>启用</span><select name="enabled" {{if not .AssetIntegrationEditing}}disabled{{end}}><option value="1" {{if .AssetIntegration.Enabled}}selected{{end}}>启用</option><option value="0" {{if not .AssetIntegration.Enabled}}selected{{end}}>停用</option></select></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetIntegration.Description}}" /></label>
|
||||
<label><span>启用</span><select name="enabled"><option value="1" {{if .AssetIntegration.Enabled}}selected{{end}}>启用</option><option value="0" {{if not .AssetIntegration.Enabled}}selected{{end}}>停用</option></select></label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<label><span>对象存储地址</span><input class="mono" name="endpoint" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>Bucket</span><input class="mono" name="bucket" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Bucket}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>Access Key</span><input class="mono" name="access_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>Secret Key</span><input class="mono" name="secret_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>对象存储地址</span><input class="mono" name="endpoint" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{end}}" /></label>
|
||||
<label><span>Bucket</span><input class="mono" name="bucket" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Bucket}}{{end}}" /></label>
|
||||
<label><span>Access Key</span><input class="mono" name="access_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{end}}" /></label>
|
||||
<label><span>Secret Key</span><input class="mono" name="secret_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{end}}" /></label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<label><span>Token 获取地址</span><input class="mono" name="get_token_url" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.GetTokenURL}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>认证用户名</span><input name="username" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Username}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>认证密码</span><input name="password" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Password}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>认证租户编码</span><input class="mono" name="tenant_code" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.TenantCode}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>Token 获取地址</span><input class="mono" name="get_token_url" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.GetTokenURL}}{{end}}" /></label>
|
||||
<label><span>认证用户名</span><input name="username" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Username}}{{end}}" /></label>
|
||||
<label><span>认证密码</span><input name="password" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Password}}{{end}}" /></label>
|
||||
<label><span>认证租户编码</span><input class="mono" name="tenant_code" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.TenantCode}}{{end}}" /></label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<label><span>消息上报地址</span><input class="mono" name="put_message_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>告警用户名</span><input name="alarm_username" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Username}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>告警密码</span><input name="alarm_password" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Password}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>告警租户编码</span><input class="mono" name="alarm_tenant_code" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.TenantCode}}{{end}}" {{if not .AssetIntegrationEditing}}readonly{{end}} /></label>
|
||||
<label><span>消息上报地址</span><input class="mono" name="put_message_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{end}}" /></label>
|
||||
<label><span>告警用户名</span><input name="alarm_username" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Username}}{{end}}" /></label>
|
||||
<label><span>告警密码</span><input name="alarm_password" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Password}}{{end}}" /></label>
|
||||
<label><span>告警租户编码</span><input class="mono" name="alarm_tenant_code" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.TenantCode}}{{end}}" /></label>
|
||||
</div>
|
||||
|
||||
{{if .AssetIntegrationEditing}}
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>服务名称</span><strong class="mono">{{.AssetIntegration.Name}}</strong></div>
|
||||
<div class="detail-item"><span>服务类型</span><strong>{{.AssetIntegration.TypeLabel}}</strong></div>
|
||||
<div class="detail-item"><span>描述</span><strong>{{if .AssetIntegration.Description}}{{.AssetIntegration.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>启用状态</span><strong>{{if .AssetIntegration.Enabled}}启用{{else}}停用{{end}}</strong></div>
|
||||
<div class="detail-item"><span>对象存储地址</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Endpoint}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>Bucket</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Bucket}}{{.AssetIntegration.ObjectStorage.Bucket}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>Access Key</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.AccessKey}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>Secret Key</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.SecretKey}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>Token 获取地址</span><strong class="mono">{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.GetTokenURL}}{{.AssetIntegration.TokenService.GetTokenURL}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>认证用户名</span><strong>{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.Username}}{{.AssetIntegration.TokenService.Username}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>认证租户编码</span><strong class="mono">{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.TenantCode}}{{.AssetIntegration.TokenService.TenantCode}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>消息上报地址</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.PutMessageURL}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>告警用户名</span><strong>{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.Username}}{{.AssetIntegration.AlarmService.Username}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>告警租户编码</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.TenantCode}}{{.AssetIntegration.AlarmService.TenantCode}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@ -158,7 +185,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetVideoSources}}
|
||||
<tr>
|
||||
<tr data-nav-row data-nav-href="/ui/assets/video-sources?name={{.Name}}"{{if and $.AssetVideoSource (eq $.AssetVideoSource.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/assets/video-sources?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{.SourceTypeLabel}}</td>
|
||||
<td>{{if .Area}}{{.Area}}{{else}}-{{end}}</td>
|
||||
@ -176,41 +203,65 @@
|
||||
</div>
|
||||
{{if .AssetVideoSource}}
|
||||
<form method="post" action="/ui/assets/video-sources">
|
||||
<div class="card">
|
||||
<div class="card editor-state {{if .AssetVideoSourceEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}{{if .AssetVideoSource.Name}} · {{.AssetVideoSource.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetVideoSourceEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>当前视频源正在编辑,保存后生效。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前视频源为只读展示。</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetVideoSourceEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>视频源名称</span><input name="name" value="{{.AssetVideoSource.Name}}" {{if .AssetVideoSourceEditing}}autofocus{{else}}readonly{{end}} /></label>
|
||||
<label><span>视频源名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetVideoSource.Name}}" autofocus /></label>
|
||||
<label>
|
||||
<span>类型</span>
|
||||
<select name="source_type" {{if not .AssetVideoSourceEditing}}disabled{{end}}>
|
||||
<span>类型<span class="required-mark">*</span></span>
|
||||
<select name="source_type">
|
||||
<option value="rtsp" {{if eq .AssetVideoSource.SourceType "rtsp"}}selected{{end}}>RTSP</option>
|
||||
<option value="rtmp" {{if eq .AssetVideoSource.SourceType "rtmp"}}selected{{end}}>RTMP</option>
|
||||
<option value="file" {{if eq .AssetVideoSource.SourceType "file"}}selected{{end}}>文件</option>
|
||||
<option value="usb_camera" {{if eq .AssetVideoSource.SourceType "usb_camera"}}selected{{end}}>USB 摄像头</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>区域</span><input name="area" value="{{.AssetVideoSource.Area}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetVideoSource.Description}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>区域</span><input name="area" value="{{.AssetVideoSource.Area}}" /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetVideoSource.Description}}" /></label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>输入地址</span><input class="mono" name="url" value="{{.AssetVideoSource.Config.URL}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>标准分辨率</span><input name="resolution" value="{{.AssetVideoSource.Config.Resolution}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>像素尺寸</span><input class="mono" name="frame_size" value="{{.AssetVideoSource.Config.FrameSize}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>帧率</span><input class="mono" name="fps" value="{{.AssetVideoSource.Config.FPS}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>视频格式</span><input name="video_format" value="{{.AssetVideoSource.Config.VideoFormat}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>焦距</span><input name="focal_length" value="{{.AssetVideoSource.Config.FocalLength}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>安装高度</span><input name="mount_height" value="{{.AssetVideoSource.Config.MountHeight}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label><span>安装角度</span><input name="mount_angle" value="{{.AssetVideoSource.Config.MountAngle}}" {{if not .AssetVideoSourceEditing}}readonly{{end}} /></label>
|
||||
<label class="full"><span>输入地址<span class="required-mark">*</span></span><input class="mono" name="url" value="{{.AssetVideoSource.Config.URL}}" /></label>
|
||||
<label><span>标准分辨率</span><input name="resolution" value="{{.AssetVideoSource.Config.Resolution}}" /></label>
|
||||
<label><span>像素尺寸</span><input class="mono" name="frame_size" value="{{.AssetVideoSource.Config.FrameSize}}" /></label>
|
||||
<label><span>帧率</span><input class="mono" name="fps" value="{{.AssetVideoSource.Config.FPS}}" /></label>
|
||||
<label><span>视频格式</span><input name="video_format" value="{{.AssetVideoSource.Config.VideoFormat}}" /></label>
|
||||
<label><span>焦距</span><input name="focal_length" value="{{.AssetVideoSource.Config.FocalLength}}" /></label>
|
||||
<label><span>安装高度</span><input name="mount_height" value="{{.AssetVideoSource.Config.MountHeight}}" /></label>
|
||||
<label><span>安装角度</span><input name="mount_angle" value="{{.AssetVideoSource.Config.MountAngle}}" /></label>
|
||||
</div>
|
||||
{{if .AssetVideoSourceEditing}}
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>视频源名称</span><strong class="mono">{{.AssetVideoSource.Name}}</strong></div>
|
||||
<div class="detail-item"><span>类型</span><strong>{{.AssetVideoSource.SourceTypeLabel}}</strong></div>
|
||||
<div class="detail-item"><span>区域</span><strong>{{if .AssetVideoSource.Area}}{{.AssetVideoSource.Area}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>描述</span><strong>{{if .AssetVideoSource.Description}}{{.AssetVideoSource.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item full"><span>输入地址</span><strong class="mono">{{if .AssetVideoSource.Config.URL}}{{.AssetVideoSource.Config.URL}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>标准分辨率</span><strong>{{if .AssetVideoSource.Config.Resolution}}{{.AssetVideoSource.Config.Resolution}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>像素尺寸</span><strong class="mono">{{if .AssetVideoSource.Config.FrameSize}}{{.AssetVideoSource.Config.FrameSize}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>帧率</span><strong class="mono">{{if .AssetVideoSource.Config.FPS}}{{.AssetVideoSource.Config.FPS}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>视频格式</span><strong>{{if .AssetVideoSource.Config.VideoFormat}}{{.AssetVideoSource.Config.VideoFormat}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>焦距</span><strong>{{if .AssetVideoSource.Config.FocalLength}}{{.AssetVideoSource.Config.FocalLength}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>安装高度</span><strong>{{if .AssetVideoSource.Config.MountHeight}}{{.AssetVideoSource.Config.MountHeight}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>安装角度</span><strong>{{if .AssetVideoSource.Config.MountAngle}}{{.AssetVideoSource.Config.MountAngle}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -4,49 +4,19 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>配置预览</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>设备分配预览</span></h2>
|
||||
</div>
|
||||
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview">
|
||||
<div class="field-grid">
|
||||
<label><span>模板</span>
|
||||
<select name="template">
|
||||
{{range .ConfigSources.Templates}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedTemplate}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>业务配置</span>
|
||||
<select name="profile">
|
||||
{{range .ConfigSources.Profiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>config_id</span><input name="config_id" value="{{.SelectedConfigID}}" /></label>
|
||||
<label><span>config_version</span><input name="config_version" value="{{.SelectedVersion}}" placeholder="留空自动生成" /></label>
|
||||
<div class="full">
|
||||
<span class="muted small">调试参数</span>
|
||||
<div class="actions" style="margin-top:6px">
|
||||
{{range .ConfigSources.Overlays}}
|
||||
<label class="btn ghost">
|
||||
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}} />
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>设备</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
|
||||
<div><span>场景模板</span><strong class="mono">{{if .DeviceAssignment}}{{.DeviceAssignment.ProfileName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>识别单元</span><strong>{{if .DeviceAssignment}}{{.DeviceAssignment.RecognitionCount}} 路{{else}}0 路{{end}}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">生成预览</button>
|
||||
{{if .ConfigPreview}}
|
||||
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate">上传为候选配置</button>
|
||||
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">应用候选配置</button>
|
||||
<input type="hidden" name="json" value="{{.ConfigPreview.JSON}}" />
|
||||
{{end}}
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
|
||||
</div>
|
||||
</form>
|
||||
@ -64,7 +34,7 @@
|
||||
<div><span>版本</span><strong class="mono">{{index .ConfigPreview.Metadata "config_version"}}</strong></div>
|
||||
<div><span>业务名称</span><strong>{{if index .ConfigPreview.Metadata "business_name"}}{{index .ConfigPreview.Metadata "business_name"}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>模板</span><strong>{{index .ConfigPreview.Metadata "template"}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{index .ConfigPreview.Metadata "profile"}}</strong></div>
|
||||
<div><span>场景模板</span><strong>{{index .ConfigPreview.Metadata "profile"}}</strong></div>
|
||||
<div><span>调试参数</span><strong class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div>
|
||||
<div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div>
|
||||
@ -77,37 +47,6 @@
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{if and (eq .ResultTitle "应用候选配置结果") .ConfigStatus}}
|
||||
<div class="card">
|
||||
<h2>应用结果摘要</h2>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<div>
|
||||
<div class="muted small">当前运行</div>
|
||||
<div class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}} / {{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}{{else}}未标记{{end}}</div>
|
||||
<div class="muted small mono" style="margin-top:6px">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
|
||||
<div class="muted small mono" style="margin-top:6px">sha: {{shortHash .ConfigStatus.Sha256}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">上一份配置</div>
|
||||
<div class="mono">{{if and .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Exists .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}} / {{if .ConfigStatus.PreviousConfig.Metadata.ConfigVersion}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigVersion}}{{else}}未标记{{end}}{{else}}-{{end}}</div>
|
||||
<div class="muted small mono" style="margin-top:6px">{{if and .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.PreviousConfig.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
|
||||
<div class="muted small mono" style="margin-top:6px">sha: {{if .ConfigStatus.PreviousConfig}}{{shortHash .ConfigStatus.PreviousConfig.Sha256}}{{end}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">候选配置</div>
|
||||
<div>{{if and .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}<span class="pill">仍存在</span>{{else}}<span class="pill ok">已清空</span>{{end}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">视觉服务</div>
|
||||
<div>{{if .ConfigStatus.MediaServer.Running}}<span class="pill ok">运行中</span>{{else}}<span class="pill bad">未运行</span>{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if and .ConfigStatus.PreviousConfig .ConfigStatus.Sha256 .ConfigStatus.PreviousConfig.Sha256 (eq .ConfigStatus.Metadata.ConfigID .ConfigStatus.PreviousConfig.Metadata.ConfigID) (eq .ConfigStatus.Metadata.ConfigVersion .ConfigStatus.PreviousConfig.Metadata.ConfigVersion) (ne .ConfigStatus.Sha256 .ConfigStatus.PreviousConfig.Sha256)}}
|
||||
<div class="muted small" style="margin-top:10px">当前运行与上一份配置回滚点的 <span class="mono">config_id/config_version</span> 相同,但文件内容不同,请以调试参数和 <span class="mono">sha</span> 为准。</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .RawText}}
|
||||
<div class="card">
|
||||
<h2>{{if .ResultTitle}}{{.ResultTitle}}{{else}}执行结果{{end}}</h2>
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></td>
|
||||
<td>
|
||||
{{if eq .Type "config_apply"}}下发识别配置
|
||||
{{if eq .Type "config_apply"}}下发设备分配
|
||||
{{else if eq .Type "reload"}}重载识别服务
|
||||
{{else if eq .Type "rollback"}}回滚识别配置
|
||||
{{else if eq .Type "media_start"}}启动视频分析服务
|
||||
|
||||
@ -5,11 +5,11 @@
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>设备工作台</h2>
|
||||
<div class="muted small">单设备查看、服务控制、配置应用、模型资源和日志指标都收敛在这里完成。</div>
|
||||
<div class="muted small">单设备查看、服务控制、设备分配应用、模型资源和日志指标都收敛在这里完成。</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览场景配置</span></a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览设备分配</span></a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -33,7 +33,7 @@
|
||||
<div class="actions compact">
|
||||
<a class="btn ghost" href="#device-overview">概览</a>
|
||||
<a class="btn ghost" href="#device-runtime">运行与服务</a>
|
||||
<a class="btn ghost" href="#device-config">场景配置</a>
|
||||
<a class="btn ghost" href="#device-config">设备分配</a>
|
||||
<a class="btn ghost" href="#device-models">模型与资源</a>
|
||||
<a class="btn ghost" href="#device-observability">日志与指标</a>
|
||||
</div>
|
||||
@ -54,7 +54,7 @@
|
||||
<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>当前业务配置</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.BusinessName}}{{.ConfigStatus.Metadata.BusinessName}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>当前场景模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{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>
|
||||
@ -71,7 +71,7 @@
|
||||
<div><span>配置 ID</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>配置版本</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>模板</span><strong>{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>场景模板</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>调试参数</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>配置文件</span><strong class="mono">{{.ConfigStatus.ConfigPath}}</strong></div>
|
||||
<div><span>配置 SHA</span><strong class="mono">{{shortHash .ConfigStatus.Sha256}}</strong></div>
|
||||
@ -82,7 +82,7 @@
|
||||
<div><span>配置 ID</span><strong class="mono">{{if .PersistedConfig.ConfigID}}{{.PersistedConfig.ConfigID}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>配置版本</span><strong class="mono">{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>模板</span><strong>{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>场景模板</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>调试参数</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>最近下发任务</span><strong class="mono">{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
@ -129,34 +129,29 @@
|
||||
<section class="panel-block" id="device-config">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "apply"}}<span>场景配置</span></h3>
|
||||
<h3 class="title-with-icon">{{icon "apply"}}<span>设备分配</span></h3>
|
||||
</div>
|
||||
</div>
|
||||
<form id="device-scene-apply-form" method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply" class="scene-config-form">
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>选择场景配置</span>
|
||||
<select name="profile">
|
||||
{{range .AssetProfiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}{{if .BusinessName}} - {{.BusinessName}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{{if .AssetProfile}}
|
||||
{{if .DeviceAssignment}}
|
||||
<details class="scene-summary-details">
|
||||
<summary>查看所选场景配置详情</summary>
|
||||
<summary>查看当前设备分配详情</summary>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>业务名称</span><strong>{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>关联模板</span><strong>{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>视频通道</span><strong>{{len .AssetProfile.Instances}} 路</strong></div>
|
||||
<div><span>说明</span><strong>{{if .AssetProfile.Description}}{{.AssetProfile.Description}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>场景模板</span><strong class="mono">{{.DeviceAssignment.ProfileName}}</strong></div>
|
||||
<div><span>识别单元</span><strong>{{.DeviceAssignment.RecognitionCount}} 路</strong></div>
|
||||
<div><span>说明</span><strong>{{if .DeviceAssignment.Description}}{{.DeviceAssignment.Description}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
</details>
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">还没有设备分配</div>
|
||||
<div class="muted">请先到“设备分配”页面为这台设备指定识别单元。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
<div class="actions scene-actions-row">
|
||||
<button type="submit" form="device-scene-apply-form" class="primary">下发场景配置</button>
|
||||
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览场景配置</span></a>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply">
|
||||
<button type="submit" class="primary">下发当前设备分配</button>
|
||||
</form>
|
||||
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="rollback" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
@ -174,7 +169,7 @@
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>模型入口</span><strong>通过模型管理页上传到设备</strong></div>
|
||||
<div><span>人脸库</span><strong>通过场景配置与基础配置维护</strong></div>
|
||||
<div><span>人脸库</span><strong>通过资源管理与基础配置维护</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload">
|
||||
|
||||
372
internal/web/ui/templates/device_assignments.html
Normal file
372
internal/web/ui/templates/device_assignments.html
Normal file
@ -0,0 +1,372 @@
|
||||
{{define "device_assignments"}}
|
||||
<div class="card assignment-board-page">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "devices"}}<span>设备分配</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .DeviceAssignmentBoard}}
|
||||
<div class="assignment-kpis">
|
||||
<div class="assignment-kpi">
|
||||
<span>识别单元</span>
|
||||
<strong>{{.DeviceAssignmentBoard.Stats.TotalUnits}}</strong>
|
||||
</div>
|
||||
<div class="assignment-kpi">
|
||||
<span>设备</span>
|
||||
<strong>{{.DeviceAssignmentBoard.Stats.TotalDevices}}</strong>
|
||||
</div>
|
||||
<div class="assignment-kpi">
|
||||
<span>已分配</span>
|
||||
<strong>{{.DeviceAssignmentBoard.Stats.AssignedUnits}}</strong>
|
||||
</div>
|
||||
<div class="assignment-kpi">
|
||||
<span>未分配</span>
|
||||
<strong>{{.DeviceAssignmentBoard.Stats.UnassignedUnits}}</strong>
|
||||
</div>
|
||||
<div class="assignment-kpi">
|
||||
<span>平均负载</span>
|
||||
<strong>{{printf "%.1f" .DeviceAssignmentBoard.Stats.AverageLoad}}</strong>
|
||||
</div>
|
||||
<div class="assignment-kpi">
|
||||
<span>满载设备</span>
|
||||
<strong>{{.DeviceAssignmentBoard.Stats.OverloadedDevices}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/device-assignments" id="assignment-board-form">
|
||||
<input type="hidden" name="board_state_json" id="assignment-board-state" value="" />
|
||||
<div class="assignment-action-bar">
|
||||
<label class="assignment-slider">
|
||||
<span>每台最多</span>
|
||||
<input type="range" min="1" max="8" step="1" value="{{.MaxUnitsPerDevice}}" id="max-units-range" name="max_units_per_device" />
|
||||
<strong id="max-units-value">{{.MaxUnitsPerDevice}} 路/台</strong>
|
||||
</label>
|
||||
<div class="actions compact">
|
||||
<button type="button" class="btn secondary" id="auto-assign-btn">自动平均分配</button>
|
||||
<button type="button" class="btn secondary" id="clear-assign-btn">清空分配</button>
|
||||
<button type="submit">保存设备分配</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="assignment-feedback" class="form-hint"></div>
|
||||
|
||||
<div class="assignment-board-grid" id="assignment-cards">
|
||||
{{range .DeviceAssignmentBoard.Cards}}
|
||||
<section class="assignment-device-card state-{{.Status}}" data-device-card="{{.DeviceID}}">
|
||||
<div class="assignment-device-head">
|
||||
<div>
|
||||
<h3>{{.DeviceName}}</h3>
|
||||
<span class="mono">{{.DeviceID}}</span>
|
||||
</div>
|
||||
<div class="assignment-device-metrics">
|
||||
<strong>{{.AssignedCount}} / {{.MaxUnits}}</strong>
|
||||
<span class="pill">{{.Status}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assignment-chip-list" data-assigned-list="{{.DeviceID}}">
|
||||
{{range .Units}}
|
||||
<span class="assignment-chip" data-unit-ref="{{.Ref}}">
|
||||
<span class="assignment-chip-text">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
|
||||
<button type="button" class="assignment-chip-remove" data-remove-unit="{{.Ref}}">×</button>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if lt .AssignedCount .MaxUnits}}
|
||||
<div class="assignment-device-add">
|
||||
<select data-add-select="{{.DeviceID}}">
|
||||
<option value="">添加识别单元</option>
|
||||
</select>
|
||||
<button type="button" class="btn secondary" data-add-button="{{.DeviceID}}">加入</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<section class="assignment-unassigned">
|
||||
<div class="section-title compact">
|
||||
<div>
|
||||
<h3>未分配识别单元</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assignment-chip-list" id="assignment-unassigned-list">
|
||||
{{range .DeviceAssignmentBoard.Unassigned}}
|
||||
<span class="assignment-chip assignment-chip-unassigned" data-unit-ref="{{.Ref}}">
|
||||
<span class="assignment-chip-text">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
|
||||
</span>
|
||||
{{else}}
|
||||
<div class="empty-state compact"><div class="empty-title">已全部分配</div></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<script type="application/json" id="assignment-board-seed">{{.DeviceAssignmentBoardJSON}}</script>
|
||||
<script>
|
||||
(() => {
|
||||
const seedEl = document.getElementById('assignment-board-seed');
|
||||
if (!seedEl) return;
|
||||
const seed = JSON.parse(seedEl.textContent || '{}');
|
||||
const state = {
|
||||
max: Number(seed.max_units_per_device || {{.MaxUnitsPerDevice}}),
|
||||
units: {},
|
||||
devices: {},
|
||||
meta: {}
|
||||
};
|
||||
(seed.cards || []).forEach(card => {
|
||||
state.meta[card.device_id] = {
|
||||
device_id: card.device_id,
|
||||
device_name: card.device_name,
|
||||
max_units: card.max_units || state.max
|
||||
};
|
||||
state.devices[card.device_id] = (card.units || []).map(unit => {
|
||||
state.units[unit.ref] = unit;
|
||||
return unit.ref;
|
||||
});
|
||||
});
|
||||
(seed.unassigned || []).forEach(unit => {
|
||||
state.units[unit.ref] = unit;
|
||||
});
|
||||
|
||||
const maxRange = document.getElementById('max-units-range');
|
||||
const maxValue = document.getElementById('max-units-value');
|
||||
const feedback = document.getElementById('assignment-feedback');
|
||||
const hiddenState = document.getElementById('assignment-board-state');
|
||||
const cardsContainer = document.getElementById('assignment-cards');
|
||||
const unassignedContainer = document.getElementById('assignment-unassigned-list');
|
||||
const form = document.getElementById('assignment-board-form');
|
||||
|
||||
function unitLabel(unit) {
|
||||
return unit.display_name || unit.name;
|
||||
}
|
||||
|
||||
function statusFor(count) {
|
||||
if (count <= 0) return 'idle';
|
||||
if (count >= state.max) return 'full';
|
||||
if (count * 2 >= state.max) return 'busy';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function statusRank(status) {
|
||||
return { full: 0, busy: 1, low: 2, idle: 3 }[status] ?? 4;
|
||||
}
|
||||
|
||||
function allRefs() {
|
||||
return Object.keys(state.units).sort((a, b) => {
|
||||
const ua = state.units[a];
|
||||
const ub = state.units[b];
|
||||
if ((ua.scene_template_name || '') !== (ub.scene_template_name || '')) {
|
||||
return (ua.scene_template_name || '').localeCompare(ub.scene_template_name || '');
|
||||
}
|
||||
return (ua.name || '').localeCompare(ub.name || '');
|
||||
});
|
||||
}
|
||||
|
||||
function assignedRefSet() {
|
||||
const seen = new Set();
|
||||
Object.values(state.devices).forEach(refs => refs.forEach(ref => seen.add(ref)));
|
||||
return seen;
|
||||
}
|
||||
|
||||
function unassignedRefs() {
|
||||
const assigned = assignedRefSet();
|
||||
return allRefs().filter(ref => !assigned.has(ref));
|
||||
}
|
||||
|
||||
function currentProfile(deviceID) {
|
||||
const refs = state.devices[deviceID] || [];
|
||||
if (!refs.length) return '';
|
||||
const unit = state.units[refs[0]];
|
||||
return unit ? (unit.scene_template_name || '') : '';
|
||||
}
|
||||
|
||||
function moveToUnassigned(ref) {
|
||||
Object.keys(state.devices).forEach(deviceID => {
|
||||
state.devices[deviceID] = (state.devices[deviceID] || []).filter(item => item !== ref);
|
||||
});
|
||||
}
|
||||
|
||||
function addToDevice(deviceID, ref) {
|
||||
if (!deviceID || !ref) return;
|
||||
moveToUnassigned(ref);
|
||||
if (!state.devices[deviceID]) state.devices[deviceID] = [];
|
||||
state.devices[deviceID].push(ref);
|
||||
}
|
||||
|
||||
function clearAssignments() {
|
||||
Object.keys(state.devices).forEach(deviceID => {
|
||||
state.devices[deviceID] = [];
|
||||
});
|
||||
}
|
||||
|
||||
function autoAssign() {
|
||||
clearAssignments();
|
||||
const deviceIDs = Object.keys(state.devices).sort();
|
||||
const units = allRefs().map(ref => state.units[ref]);
|
||||
units.forEach(unit => {
|
||||
let target = null;
|
||||
deviceIDs.forEach(deviceID => {
|
||||
const refs = state.devices[deviceID] || [];
|
||||
const profile = currentProfile(deviceID);
|
||||
if (profile && profile !== unit.scene_template_name) return;
|
||||
if (refs.length >= state.max) return;
|
||||
if (!target || refs.length < (state.devices[target] || []).length) {
|
||||
target = deviceID;
|
||||
}
|
||||
});
|
||||
if (target) {
|
||||
state.devices[target].push(unit.ref);
|
||||
}
|
||||
});
|
||||
const remaining = unassignedRefs().length;
|
||||
feedback.textContent = `已按每台最多 ${state.max} 路完成自动分配${remaining > 0 ? `,剩余 ${remaining} 路未分配。` : '。'}`;
|
||||
render();
|
||||
}
|
||||
|
||||
function syncHiddenState() {
|
||||
hiddenState.value = JSON.stringify({ devices: state.devices });
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const assigned = assignedRefSet();
|
||||
const totalUnits = allRefs().length;
|
||||
const totalDevices = Object.keys(state.devices).length;
|
||||
const average = totalDevices ? (assigned.size / totalDevices).toFixed(1) : '0.0';
|
||||
const full = Object.keys(state.devices).filter(deviceID => statusFor((state.devices[deviceID] || []).length) === 'full').length;
|
||||
const values = document.querySelectorAll('.assignment-kpi strong');
|
||||
if (values.length >= 6) {
|
||||
values[0].textContent = totalUnits;
|
||||
values[1].textContent = totalDevices;
|
||||
values[2].textContent = assigned.size;
|
||||
values[3].textContent = totalUnits - assigned.size;
|
||||
values[4].textContent = average;
|
||||
values[5].textContent = full;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
const cards = Object.keys(state.devices).map(deviceID => {
|
||||
const refs = state.devices[deviceID] || [];
|
||||
return {
|
||||
deviceID,
|
||||
deviceName: (state.meta[deviceID] && state.meta[deviceID].device_name) || deviceID,
|
||||
refs,
|
||||
status: statusFor(refs.length)
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
const rankDiff = statusRank(a.status) - statusRank(b.status);
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
if (b.refs.length !== a.refs.length) return b.refs.length - a.refs.length;
|
||||
return a.deviceID.localeCompare(b.deviceID);
|
||||
});
|
||||
|
||||
cardsContainer.innerHTML = '';
|
||||
cards.forEach(card => {
|
||||
const section = document.createElement('section');
|
||||
section.className = `assignment-device-card state-${card.status}`;
|
||||
const currentTemplate = currentProfile(card.deviceID);
|
||||
const availableRefs = unassignedRefs().filter(ref => {
|
||||
const unit = state.units[ref];
|
||||
return !currentTemplate || unit.scene_template_name === currentTemplate;
|
||||
});
|
||||
const addControls = card.refs.length < state.max ? `
|
||||
<div class="assignment-device-add">
|
||||
<select data-add-select="${card.deviceID}">
|
||||
<option value="">添加识别单元</option>
|
||||
${availableRefs.map(ref => `<option value="${ref}">${unitLabel(state.units[ref])}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn secondary" data-add-button="${card.deviceID}">加入</button>
|
||||
</div>
|
||||
` : '';
|
||||
section.innerHTML = `
|
||||
<div class="assignment-device-head">
|
||||
<div>
|
||||
<h3>${card.deviceName}</h3>
|
||||
<span class="mono">${card.deviceID}</span>
|
||||
</div>
|
||||
<div class="assignment-device-metrics">
|
||||
<strong>${card.refs.length} / ${state.max}</strong>
|
||||
<span class="pill">${card.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assignment-chip-list"></div>
|
||||
${addControls}
|
||||
`;
|
||||
const chipList = section.querySelector('.assignment-chip-list');
|
||||
card.refs.forEach(ref => {
|
||||
const unit = state.units[ref];
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'assignment-chip';
|
||||
chip.innerHTML = `<span class="assignment-chip-text">${unitLabel(unit)}</span><button type="button" class="assignment-chip-remove" data-remove-unit="${ref}">×</button>`;
|
||||
chipList.appendChild(chip);
|
||||
});
|
||||
cardsContainer.appendChild(section);
|
||||
});
|
||||
}
|
||||
|
||||
function renderUnassigned() {
|
||||
const refs = unassignedRefs();
|
||||
if (!refs.length) {
|
||||
unassignedContainer.innerHTML = '<div class="empty-state compact"><div class="empty-title">已全部分配</div></div>';
|
||||
return;
|
||||
}
|
||||
unassignedContainer.innerHTML = '';
|
||||
refs.forEach(ref => {
|
||||
const unit = state.units[ref];
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'assignment-chip assignment-chip-unassigned';
|
||||
chip.innerHTML = `<span class="assignment-chip-text">${unitLabel(unit)}</span>`;
|
||||
unassignedContainer.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
maxValue.textContent = `${state.max} 路/台`;
|
||||
renderStats();
|
||||
renderCards();
|
||||
renderUnassigned();
|
||||
syncHiddenState();
|
||||
}
|
||||
|
||||
maxRange.addEventListener('input', () => {
|
||||
state.max = Number(maxRange.value || state.max);
|
||||
render();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const remove = event.target.closest('[data-remove-unit]');
|
||||
if (remove) {
|
||||
moveToUnassigned(remove.getAttribute('data-remove-unit'));
|
||||
feedback.textContent = '';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const add = event.target.closest('[data-add-button]');
|
||||
if (add) {
|
||||
const deviceID = add.getAttribute('data-add-button');
|
||||
const select = document.querySelector(`[data-add-select="${deviceID}"]`);
|
||||
if (select && select.value) {
|
||||
addToDevice(deviceID, select.value);
|
||||
feedback.textContent = '';
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('auto-assign-btn').addEventListener('click', autoAssign);
|
||||
document.getElementById('clear-assign-btn').addEventListener('click', () => {
|
||||
clearAssignments();
|
||||
feedback.textContent = '已清空当前页面中的设备分配。';
|
||||
render();
|
||||
});
|
||||
form.addEventListener('submit', syncHiddenState);
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
{{else}}
|
||||
<div class="empty-state compact"><div class="empty-title">暂无可用的设备分配数据</div></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@ -27,23 +27,13 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>下发场景配置</span></h2>
|
||||
<div class="muted">先选择一份已有场景配置,再为已选设备创建下发任务。</div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>下发设备分配</span></h2>
|
||||
<div class="muted">按每台设备当前保存的设备分配,批量创建下发任务。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/batch-config">
|
||||
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>场景配置</span>
|
||||
<select name="profile">
|
||||
{{range .AssetProfiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}{{if .BusinessName}} - {{.BusinessName}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary">创建下发任务</button>
|
||||
</div>
|
||||
@ -53,50 +43,34 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>场景配置摘要</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>设备分配摘要</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetProfile}}
|
||||
<div class="info-list">
|
||||
<div><span>场景配置</span><strong>{{.AssetProfile.Name}}</strong></div>
|
||||
<div><span>业务名称</span><strong>{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>关联模板</span><strong>{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>视频通道</span><strong>{{len .AssetProfile.Instances}} 路</strong></div>
|
||||
{{with index .AssetProfile.Instances 0}}
|
||||
<div><span>首个通道</span><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</strong></div>
|
||||
{{end}}
|
||||
{{if .AssetProfile.Description}}
|
||||
<div class="full"><span>说明</span><strong>{{.AssetProfile.Description}}</strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .AssetProfile.Instances}}
|
||||
<div class="table-wrap" style="margin-top:14px">
|
||||
{{if .DeviceAssignments}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>通道</th>
|
||||
<th>显示名称</th>
|
||||
<th>站点</th>
|
||||
<th>视频源</th>
|
||||
<th>设备</th>
|
||||
<th>场景模板</th>
|
||||
<th>识别单元</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetProfile.Instances}}
|
||||
{{range .DeviceAssignments}}
|
||||
<tr>
|
||||
<td class="mono">{{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}}</td>
|
||||
<td>{{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}}</td>
|
||||
<td>{{if .SiteName}}{{.SiteName}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .VideoSourceRef}}{{.VideoSourceRef}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{.DeviceID}}</td>
|
||||
<td class="mono">{{if .ProfileName}}{{.ProfileName}}{{else}}-{{end}}</td>
|
||||
<td>{{.RecognitionCount}} 路</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">还没有可用场景配置</div>
|
||||
<div class="muted">请先到场景配置中创建配置,再回来下发。</div>
|
||||
<div class="empty-title">还没有可用设备分配</div>
|
||||
<div class="muted">请先到设备分配中完成设备与识别单元绑定,再回来下发。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>单设备配置已并入设备详情</h2>
|
||||
<div class="muted small">配置查看、服务控制、候选配置应用和回滚,都已经收敛到设备详情工作台。</div>
|
||||
<div class="muted small">配置查看、服务控制和回滚,都已经收敛到设备详情工作台。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/devices">去设备列表</a>
|
||||
</div>
|
||||
|
||||
@ -38,8 +38,8 @@
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>当前模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>业务配置</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>候选配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}} / {{.ConfigStatus.Candidate.Metadata.ConfigVersion}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
|
||||
<div><span>场景模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>待应用配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}} / {{.ConfigStatus.Candidate.Metadata.ConfigVersion}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="primary">应用候选配置</button>
|
||||
<button type="submit" class="primary">应用待应用配置</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="rollback" />
|
||||
|
||||
@ -42,9 +42,9 @@
|
||||
<button type="submit" name="action" value="media_restart" class="primary">重启服务</button>
|
||||
<button type="submit" name="action" value="media_start" class="secondary">启动服务</button>
|
||||
<button type="submit" name="action" value="media_stop" class="danger">停止服务</button>
|
||||
<button type="submit" name="action" value="reload" class="secondary" {{if .ReloadSummary}}onclick='return confirm("将重载当前场景配置:{{.ReloadSummary}}")'{{end}}>重载配置</button>
|
||||
<button type="submit" name="action" value="rollback" class="secondary" {{if .RollbackSummary}}onclick='return confirm("将回滚到上一版场景配置:{{.RollbackSummary}}")'{{end}}>回滚配置</button>
|
||||
<a class="btn secondary" href="{{.BatchConfigURL}}">下发场景配置</a>
|
||||
<button type="submit" name="action" value="reload" class="secondary" {{if .ReloadSummary}}onclick='return confirm("将重载当前运行配置:{{.ReloadSummary}}")'{{end}}>重载运行配置</button>
|
||||
<button type="submit" name="action" value="rollback" class="secondary" {{if .RollbackSummary}}onclick='return confirm("将回滚到上一版运行配置:{{.RollbackSummary}}")'{{end}}>回滚运行配置</button>
|
||||
<a class="btn secondary" href="{{.BatchConfigURL}}">下发设备分配</a>
|
||||
<a class="btn secondary" href="/ui/devices">清空选择</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -22,7 +22,9 @@
|
||||
<div class="nav-section">主模块</div>
|
||||
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
|
||||
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
|
||||
<a href="/ui/plans"><span class="nav-icon">{{icon "profile"}}</span><span>场景配置</span></a>
|
||||
<a href="/ui/scene-templates"><span class="nav-icon">{{icon "profile"}}</span><span>场景模板</span></a>
|
||||
<a href="/ui/recognition-units"><span class="nav-icon">{{icon "device"}}</span><span>识别单元</span></a>
|
||||
<a href="/ui/device-assignments"><span class="nav-icon">{{icon "apply"}}</span><span>设备分配</span></a>
|
||||
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>基础配置</span></a>
|
||||
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务</span></a>
|
||||
<details class="nav-group" id="system-nav-group">
|
||||
@ -251,6 +253,24 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const profileRow = event.target.closest("[data-profile-row]");
|
||||
if (profileRow && !event.target.closest("button, a, input, select, textarea")) {
|
||||
const href = profileRow.getAttribute("data-profile-href");
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const navRow = event.target.closest("[data-nav-row]");
|
||||
if (navRow && !event.target.closest("button, a, input, select, textarea")) {
|
||||
const href = navRow.getAttribute("data-nav-href");
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceEditorToggle = event.target.closest(".js-open-instance-editor");
|
||||
if (instanceEditorToggle) {
|
||||
event.preventDefault();
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="model-summary">
|
||||
<div class="summary-item"><div class="summary-label">模型目录</div><div class="summary-value">统一管理</div><div class="summary-hint">检测 / 识别模型统一发布</div></div>
|
||||
<div class="summary-item"><div class="summary-label">发布版本</div><div class="summary-value">当前版本</div><div class="summary-hint">按场景配置引用生效</div></div>
|
||||
<div class="summary-item"><div class="summary-label">发布版本</div><div class="summary-value">当前版本</div><div class="summary-hint">按场景模板与识别单元引用生效</div></div>
|
||||
<div class="summary-item"><div class="summary-label">设备版本状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备版本覆盖</div></div>
|
||||
<div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">统一管理</div><div class="summary-hint">在人脸库资源中维护</div></div>
|
||||
</div>
|
||||
|
||||
107
internal/web/ui/templates/recognition_units.html
Normal file
107
internal/web/ui/templates/recognition_units.html
Normal file
@ -0,0 +1,107 @@
|
||||
{{define "recognition_units"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>识别单元列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/recognition-units?new=1">{{icon "apply"}}<span>新增识别单元</span></a>
|
||||
{{if .SelectedRecognitionUnit}}
|
||||
<a class="btn secondary" href="/ui/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
|
||||
<form method="post" action="/ui/recognition-units/delete" onsubmit="return confirm('确认删除这个识别单元吗?');">
|
||||
<input type="hidden" name="ref" value="{{.SelectedRecognitionUnit}}" />
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>识别单元</th>
|
||||
<th>场景模板</th>
|
||||
<th>视频源</th>
|
||||
<th>输出频道号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecognitionUnits}}
|
||||
<tr data-nav-row data-nav-href="/ui/recognition-units?ref={{.Ref}}" {{if eq $.SelectedRecognitionUnit .Ref}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/recognition-units?ref={{.Ref}}">{{.Name}}</a>{{if .DisplayName}}<div class="stacked-meta"><span>{{.DisplayName}}</span></div>{{end}}</td>
|
||||
<td class="mono">{{.SceneTemplateName}}</td>
|
||||
<td class="mono">{{if .VideoSourceRef}}{{.VideoSourceRef}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .OutputChannel}}{{.OutputChannel}}{{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 .RecognitionUnit}}
|
||||
<form method="post" action="/ui/recognition-units">
|
||||
<input type="hidden" name="original_ref" value="{{.SelectedRecognitionUnit}}" />
|
||||
<div class="card editor-state {{if .RecognitionUnitEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>识别单元{{if .RecognitionUnit.Name}} · {{.RecognitionUnit.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>一路视频对应一个识别单元,由设备分配决定最终在哪台设备上运行。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<button type="submit">{{icon "apply"}}<span>保存识别单元</span></button>
|
||||
<a class="btn secondary" href="/ui/recognition-units{{if .SelectedRecognitionUnit}}?ref={{.SelectedRecognitionUnit}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>识别单元名称<span class="required-mark">*</span></span><input name="name" value="{{.RecognitionUnit.Name}}" {{if not .SelectedRecognitionUnit}}autofocus{{end}} /></label>
|
||||
<label>
|
||||
<span>场景模板<span class="required-mark">*</span></span>
|
||||
<select name="scene_template_name">
|
||||
{{range .AssetProfiles}}
|
||||
<option value="{{.Name}}" {{if eq $.RecognitionUnit.SceneTemplateName .Name}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>通道显示名</span><input name="display_name" value="{{.RecognitionUnit.DisplayName}}" /></label>
|
||||
<label><span>站点名</span><input name="site_name" value="{{.RecognitionUnit.SiteName}}" /></label>
|
||||
<label>
|
||||
<span>视频源<span class="required-mark">*</span></span>
|
||||
<select name="video_source_ref">
|
||||
<option value="">未选择</option>
|
||||
{{range .AssetVideoSources}}
|
||||
<option value="{{.Name}}" {{if eq $.RecognitionUnit.VideoSourceRef .Name}}selected{{end}}>{{.Name}}{{if .Area}} - {{.Area}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>输出频道号<span class="required-mark">*</span></span><input name="output_channel" value="{{.RecognitionUnit.OutputChannel}}" /></label>
|
||||
<label><span>RTSP 端口</span><input class="mono" name="rtsp_port" value="{{.RecognitionUnit.RTSPPort}}" /></label>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>识别单元名称</span><strong class="mono">{{if .RecognitionUnit.Name}}{{.RecognitionUnit.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>场景模板</span><strong class="mono">{{if .RecognitionUnit.SceneTemplateName}}{{.RecognitionUnit.SceneTemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>通道显示名</span><strong>{{if .RecognitionUnit.DisplayName}}{{.RecognitionUnit.DisplayName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>站点名</span><strong>{{if .RecognitionUnit.SiteName}}{{.RecognitionUnit.SiteName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>视频源</span><strong class="mono">{{if .RecognitionUnit.VideoSourceRef}}{{.RecognitionUnit.VideoSourceRef}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>输出频道号</span><strong class="mono">{{if .RecognitionUnit.OutputChannel}}{{.RecognitionUnit.OutputChannel}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>RTSP 端口</span><strong class="mono">{{if .RecognitionUnit.RTSPPort}}{{.RecognitionUnit.RTSPPort}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@ -10,7 +10,7 @@
|
||||
<div class="summary-item"><div class="summary-label">人脸库版本</div><div class="summary-value">统一管理</div><div class="summary-hint">由平台集中维护与发布</div></div>
|
||||
<div class="summary-item"><div class="summary-label">通用资源</div><div class="summary-value">统一管理</div><div class="summary-hint">标定、字典与业务资源统一维护</div></div>
|
||||
<div class="summary-item"><div class="summary-label">设备资源状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备资源版本覆盖</div></div>
|
||||
<div class="summary-item"><div class="summary-label">同步方式</div><div class="summary-value">任务下发</div><div class="summary-hint">与场景配置、模型同步一致</div></div>
|
||||
<div class="summary-item"><div class="summary-label">同步方式</div><div class="summary-value">任务下发</div><div class="summary-hint">与设备分配、模型同步一致</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
105
internal/web/ui/templates/scene_templates.html
Normal file
105
internal/web/ui/templates/scene_templates.html
Normal file
@ -0,0 +1,105 @@
|
||||
{{define "scene_templates"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/scene-templates?new=1">{{icon "apply"}}<span>新建场景模板</span></a>
|
||||
{{if .SelectedProfile}}
|
||||
<a class="btn secondary" href="/ui/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
|
||||
<form method="post" action="/ui/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景模板吗?');">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>场景模板</th>
|
||||
<th>识别模板</th>
|
||||
<th>调试参数</th>
|
||||
<th>识别单元</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetProfiles}}
|
||||
<tr data-nav-row data-nav-href="/ui/scene-templates?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/scene-templates?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if index .Raw "primary_template_name"}}{{index .Raw "primary_template_name"}}{{else if .Instances}}{{(index .Instances 0).Template}}{{else}}-{{end}}</td>
|
||||
<td>{{if $.AssetProfileEditor}}{{if and (eq $.AssetProfileEditor.Name .Name) $.AssetProfileEditor.OverlayName}}{{$.AssetProfileEditor.OverlayName}}{{else}}-{{end}}{{else}}-{{end}}</td>
|
||||
<td>{{len .Instances}}</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 .AssetProfileEditor}}
|
||||
{{if .AssetProfileEditing}}<form method="post" action="{{.AssetProfileFormAction}}">{{end}}
|
||||
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetProfileEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>当前内容只包含模板级信息,识别单元请到“识别单元”页面维护。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
{{if .AssetProfileEditing}}
|
||||
<button type="submit">{{icon "apply"}}<span>保存场景模板</span></button>
|
||||
<a class="btn secondary" href="/ui/scene-templates{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
{{end}}
|
||||
<button type="button" class="btn secondary js-export-json" data-export-url="/ui/scene-templates/{{.AssetProfileEditor.Name}}/export" data-default-filename="{{.AssetProfileEditor.Name}}.json">{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .AssetProfileEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>场景模板名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if not .SelectedProfile}}autofocus{{end}} /></label>
|
||||
<label><span>识别模板<span class="required-mark">*</span></span>
|
||||
<select name="primary_template_name">
|
||||
{{range .AssetTemplates}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.PrimaryTemplateName .Name}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
|
||||
<label>
|
||||
<span>调试参数</span>
|
||||
<select name="overlay_name">
|
||||
<option value="">不使用</option>
|
||||
{{range .AssetOverlays}}
|
||||
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.OverlayName .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
|
||||
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>场景模板名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>识别模板</span><strong class="mono">{{if .AssetProfileEditor.PrimaryTemplateName}}{{.AssetProfileEditor.PrimaryTemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>业务名称</span><strong>{{if .AssetProfileEditor.BusinessName}}{{.AssetProfileEditor.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>调试参数</span><strong>{{if .AssetProfileEditor.OverlayName}}{{.AssetProfileEditor.OverlayName}}{{else}}不使用{{end}}</strong></div>
|
||||
<div class="detail-item"><span>站点名</span><strong>{{if .AssetProfileEditor.SiteName}}{{.AssetProfileEditor.SiteName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>识别单元</span><strong>{{len .AssetProfileEditor.Instances}} 路</strong></div>
|
||||
<div class="detail-item full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .AssetProfileEditing}}</form>{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user