diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index 9a90c88..46b1c66 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -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 } diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index ed5e76c..d7621b8 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -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) } } diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go index 8242bf8..ba242af 100644 --- a/internal/service/config_preview.go +++ b/internal/service/config_preview.go @@ -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 } diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index 7cb96f3..b62165f 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -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{ diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go index 7976aab..eabc8ac 100644 --- a/internal/service/profile_editor.go +++ b/internal/service/profile_editor.go @@ -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) diff --git a/internal/service/standard_templates.go b/internal/service/standard_templates.go index 8b62b31..aa24f6a 100644 --- a/internal/service/standard_templates.go +++ b/internal/service/standard_templates.go @@ -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 } diff --git a/internal/service/task.go b/internal/service/task.go index 2fcccea..8ebc69c 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -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 } diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index 1dc9954..80abf65 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -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) diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go index e891ecb..9db8377 100644 --- a/internal/storage/assets_repo_test.go +++ b/internal/storage/assets_repo_test.go @@ -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) } } diff --git a/internal/storage/device_config_state_repo.go b/internal/storage/device_config_state_repo.go index cefca59..8b646be 100644 --- a/internal/storage/device_config_state_repo.go +++ b/internal/storage/device_config_state_repo.go @@ -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 +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index 443722f..51b1056 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -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 "" +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 6037400..1055b10 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -36,6 +36,11 @@ type UI struct { tpl *template.Template } +const ( + deviceAssignmentPreviewDevicePrefix = "demo-edge-" + deviceAssignmentPreviewDeviceCount = 8 +) + type PageData struct { Title string ContentHTML template.HTML @@ -60,6 +65,8 @@ type PageData struct { ResultTitle string SelectedTemplate string SelectedProfile string + SelectedRecognitionUnit string + SelectedAssignmentDevice string SelectedOverlays []string SelectedConfigID string SelectedVersion string @@ -71,10 +78,22 @@ type PageData struct { AssetTab string AssetTemplates []service.ConfigTemplateAsset AssetTemplate *service.ConfigTemplateAsset + AssetTemplateEditing bool AssetProfiles []service.ConfigProfileAsset AssetProfile *service.ConfigProfileAsset AssetProfileEditor *service.ConfigProfileEditor + AssetProfileEditing bool + AssetProfileFormAction string ActiveInstanceIndex int + RecognitionUnits []service.RecognitionUnitAsset + RecognitionUnit *service.RecognitionUnitAsset + RecognitionUnitEditing bool + DeviceAssignments []service.DeviceAssignmentAsset + DeviceAssignment *service.DeviceAssignmentAsset + DeviceAssignmentEditing bool + DeviceAssignmentBoard *service.DeviceAssignmentBoard + DeviceAssignmentBoardJSON template.JS + MaxUnitsPerDevice int AssetTemplateMap map[string]service.ConfigTemplateAsset AssetVideoSources []service.ConfigVideoSourceAsset AssetVideoSource *service.ConfigVideoSourceAsset @@ -84,6 +103,7 @@ type PageData struct { AssetIntegrationEditing bool AssetOverlays []service.ConfigOverlayAsset AssetOverlay *service.ConfigOverlayAsset + AssetOverlayEditing bool AssetInstanceCount int SelectedDeviceIDs []string SelectedDevices []*models.Device @@ -96,6 +116,7 @@ type PageData struct { TemplateDraftDescription string TemplateCloneSource string TemplateCreateMode string + OverlayDraftJSON string AuditEntries []storage.AuditLogRecord PersistedConfig *storage.DeviceConfigStateRecord DBPath string @@ -218,7 +239,7 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic "taskActionLabel": func(v any) string { switch fmt.Sprint(v) { case "config_apply": - return "下发识别配置" + return "下发设备分配" case "reload": return "重载识别服务" case "rollback": @@ -282,11 +303,11 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic "auditActionLabel": func(v string) string { switch strings.TrimSpace(v) { case "config_apply": - return "下发场景配置" + return "下发设备分配" case "reload": - return "重载配置" + return "重载运行配置" case "rollback": - return "回滚配置" + return "回滚运行配置" case "media_start": return "启动服务" case "media_restart": @@ -490,16 +511,27 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/dashboard", u.pageDashboard) r.Get("/devices", u.pageDevices) r.Get("/devices/{id}/control", u.pageDeviceControl) - r.Get("/plans", u.pagePlans) - r.Get("/plans/{name}", u.pagePlan) - r.Post("/plans/{name}", u.actionPlanSave) - r.Get("/plans/{name}/export", u.pagePlanExport) + r.Get("/plans", u.redirectPlansToSceneTemplates) + r.Get("/plans/{name}", u.redirectPlanToSceneTemplate) + r.Get("/scene-templates", u.pagePlans) + r.Get("/scene-templates/{name}", u.pagePlan) + r.Post("/scene-templates/create", u.actionPlanCreate) + r.Post("/scene-templates/{name}", u.actionPlanSave) + r.Post("/scene-templates/{name}/delete", u.actionPlanDelete) + r.Get("/scene-templates/{name}/export", u.pagePlanExport) + r.Get("/recognition-units", u.pageRecognitionUnits) + r.Post("/recognition-units", u.actionRecognitionUnitSave) + r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete) + r.Get("/device-assignments", u.pageDeviceAssignments) + r.Post("/device-assignments", u.actionDeviceAssignmentSave) + r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete) r.Get("/assets", u.pageAssets) r.Get("/assets/video-sources", u.pageAssetVideoSources) r.Post("/assets/video-sources", u.actionAssetVideoSourceSave) r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete) r.Get("/assets/templates", u.pageAssetTemplates) r.Post("/assets/templates/create", u.actionAssetTemplateCreate) + r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone) r.Get("/assets/templates/{name}", u.pageAssetTemplate) r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename) r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete) @@ -514,6 +546,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Post("/assets/integrations", u.actionAssetIntegrationSave) r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete) r.Get("/assets/overlays", u.pageAssetOverlays) + r.Post("/assets/overlays", u.actionAssetOverlaySave) + r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete) r.Get("/assets/overlays/{name}", u.pageAssetOverlay) r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) r.Get("/audit", u.pageAudit) @@ -765,60 +799,39 @@ func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) - req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))} data := u.deviceBatchConfigPageData(r, selectedIDs) - if req.Profile != "" { - data.SelectedProfile = req.Profile - } - for i := range data.AssetProfiles { - if strings.TrimSpace(data.AssetProfiles[i].Name) == data.SelectedProfile { - data.AssetProfile = &data.AssetProfiles[i] - data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i]) - break - } - } if len(selectedIDs) == 0 { - data.Error = "请先选择需要下发配置的设备" + data.Error = "请先选择需要下发分配的设备" u.render(w, r, "device_batch_config", data) return } - if req.Profile == "" { - req.Profile = data.SelectedProfile - } - if req.Profile == "" { - data.Error = "请先选择场景配置" - u.render(w, r, "device_batch_config", data) - return - } - if data.SelectedTemplate == "" { - data.Error = "所选场景配置缺少可用模板,无法生成下发内容" - u.render(w, r, "device_batch_config", data) - return - } - req.Template = data.SelectedTemplate if u.tasks == nil { data.Error = "task service not initialized" u.render(w, r, "device_batch_config", data) return } - - preview, err := u.preview.Render(req) - data.ConfigPreview = preview - if err != nil { - data.Error = err.Error() - u.render(w, r, "device_batch_config", data) - return + configs := make(map[string]any, len(selectedIDs)) + for _, deviceID := range selectedIDs { + preview, err := u.preview.RenderDeviceAssignment(deviceID) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device_batch_config", data) + return + } + if data.ConfigPreview == nil { + data.ConfigPreview = preview + } + var configDoc any + if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { + data.Error = "生成配置 JSON 无效: " + err.Error() + u.render(w, r, "device_batch_config", data) + return + } + configs[deviceID] = configDoc } - var configDoc any - if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { - data.Error = "生成配置 JSON 无效: " + err.Error() - u.render(w, r, "device_batch_config", data) - return - } - - task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"config": configDoc}) + task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs}) if err != nil { data.Error = err.Error() u.render(w, r, "device_batch_config", data) @@ -874,6 +887,11 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData { } else if data.Error == "" { data.Error = err.Error() } + if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { + data.DeviceAssignment = assignment + data.SelectedAssignmentDevice = assignment.DeviceID + data.SelectedProfile = assignment.ProfileName + } } return data } @@ -1290,21 +1308,7 @@ func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } - data.TemplateDraftName = "new_template" - data.TemplateCreateMode = strings.TrimSpace(r.URL.Query().Get("mode")) - if data.TemplateCreateMode == "blank" { - data.TemplateDraftDescription = "从空白流程开始。仅建议在明确了解节点依赖和处理链约束时使用。" - } - if cloneName := strings.TrimSpace(r.URL.Query().Get("clone")); cloneName != "" { - if item, err := u.preview.GetTemplateAsset(cloneName); err == nil && item != nil { - data.TemplateCloneSource = item.Name - data.TemplateCreateMode = "clone" - data.TemplateDraftName = item.Name + "_copy" - data.TemplateDraftDescription = item.Description - } else if data.Error == "" && err != nil { - data.Error = err.Error() - } - } + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { if item, err := u.preview.GetTemplateAsset(name); err == nil { data.AssetTemplate = item @@ -1318,6 +1322,7 @@ func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { data.Error = err.Error() } } + data.AssetTemplateEditing = editMode && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly u.render(w, r, "asset_templates", data) } @@ -1381,6 +1386,72 @@ func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(name)+"/graph?msg="+urlQueryEscape("模板已创建"), http.StatusFound) } +func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + sourceName := chi.URLParam(r, "name") + item, err := u.preview.GetTemplateAsset(sourceName) + if err != nil || item == nil { + http.NotFound(w, r) + return + } + if !item.ReadOnly { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound) + return + } + targetName, err := u.nextTemplateCloneName(item.Name) + if err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + body, err := json.Marshal(item.Raw) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc := map[string]any{} + if err := json.Unmarshal(body, &doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc["name"] = targetName + doc["description"] = item.Description + pretty, err := json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := u.preview.SaveTemplateAsset(targetName, item.Description, string(pretty)+"\n"); err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&edit=1&msg="+urlQueryEscape("标准模板已复制,请继续编辑"), http.StatusFound) +} + +func (u *UI) nextTemplateCloneName(sourceName string) (string, error) { + base := strings.TrimSpace(sourceName) + if base == "" { + return "", fmt.Errorf("template name is required") + } + if strings.HasPrefix(base, "std_") { + base = strings.TrimPrefix(base, "std_") + } + candidate := base + "_copy" + if item, err := u.preview.GetTemplateAsset(candidate); err == nil && item != nil { + for i := 2; i < 1000; i++ { + name := fmt.Sprintf("%s_copy_%d", base, i) + item, err := u.preview.GetTemplateAsset(name) + if err != nil || item == nil { + return name, nil + } + } + return "", fmt.Errorf("无法生成可用的模板副本名称") + } + return candidate, nil +} + func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data := u.assetPageData("templates") @@ -1595,16 +1666,36 @@ func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) { func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("") - data.Title = "场景配置" + data.Title = "场景模板" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" selected := strings.TrimSpace(r.URL.Query().Get("name")) - if selected == "" && len(data.AssetProfiles) > 0 { + if selected == "" && !newMode && len(data.AssetProfiles) > 0 { selected = data.AssetProfiles[0].Name } + if newMode { + editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} + if len(data.AssetTemplates) > 0 { + editor.PrimaryTemplateName = data.AssetTemplates[0].Name + } + data.AssetProfileEditor = editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) + u.render(w, r, "scene_templates", data) + return + } if selected != "" { editor, err := u.preview.GetProfileEditor(selected) if err == nil { data.AssetProfileEditor = editor data.SelectedProfile = editor.Name + data.AssetProfileEditing = editMode + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(editor.Name) if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { data.SelectedTemplate = editor.Instances[0].Template } @@ -1612,8 +1703,17 @@ func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { } else if data.Error == "" { data.Error = err.Error() } + } else if len(data.AssetProfiles) == 0 { + editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} + if len(data.AssetTemplates) > 0 { + editor.PrimaryTemplateName = data.AssetTemplates[0].Name + } + data.AssetProfileEditor = editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) } - u.render(w, r, "plans", data) + u.render(w, r, "scene_templates", data) } func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) { @@ -1627,9 +1727,11 @@ func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - data.Title = "场景配置" + data.Title = "场景模板" + data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name) data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query())) - u.render(w, r, "plans", data) + u.render(w, r, "scene_templates", data) } func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) { @@ -1645,42 +1747,87 @@ func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) { } func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - editor, data, err := u.profileEditorActionData(r, name) - if err != nil { - http.NotFound(w, r) - return - } - if strings.TrimSpace(r.FormValue("add_instance")) == "1" { - data.AssetProfileEditor = &editor - data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), len(editor.Instances)-1) - data.Title = "场景配置" - u.render(w, r, "plans", data) - return - } - if raw := strings.TrimSpace(r.FormValue("remove_instance")); raw != "" { - if idx, convErr := strconv.Atoi(raw); convErr == nil && idx >= 0 && idx < len(editor.Instances) { - editor.Instances = append(editor.Instances[:idx], editor.Instances[idx+1:]...) + u.actionPlanSaveWithName(w, r, chi.URLParam(r, "name")) +} + +func (u *UI) actionPlanCreate(w http.ResponseWriter, r *http.Request) { + u.actionPlanSaveWithName(w, r, "") +} + +func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name string) { + var ( + editor service.ConfigProfileEditor + data PageData + err error + ) + if strings.TrimSpace(name) == "" { + data = u.assetPageData("") + data.Title = "场景模板" + _ = r.ParseForm() + editor = service.ConfigProfileEditor{ + Name: strings.TrimSpace(r.FormValue("profile_name")), + PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")), + BusinessName: strings.TrimSpace(r.FormValue("business_name")), + Description: strings.TrimSpace(r.FormValue("description")), + OverlayName: strings.TrimSpace(r.FormValue("overlay_name")), + SiteName: strings.TrimSpace(r.FormValue("site_name")), + Queue: service.DefaultConfigProfileQueue(), + Instances: parseProfileInstanceForm(r.Form), } data.AssetProfileEditor = &editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) - data.Title = "场景配置" - u.render(w, r, "plans", data) - return + } else { + editor, data, err = u.profileEditorActionData(r, name) + if err != nil { + http.NotFound(w, r) + return + } + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name) } if err := u.preview.SaveProfileEditor(editor); err != nil { data.Error = err.Error() - data.Title = "场景配置" - u.render(w, r, "plans", data) + data.Title = "场景模板" + data.AssetProfileEditing = true + u.render(w, r, "scene_templates", data) return } - if editor.Name != name { - data.Message = "场景配置已保存,名称已更新" - } else { - data.Message = "场景配置已保存" + msg := "场景模板已保存" + if strings.TrimSpace(name) != "" && editor.Name != name { + msg = "场景模板已保存,名称已更新" } - data.Title = "场景配置" - u.render(w, r, "plans", data) + http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound) +} + +func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(chi.URLParam(r, "name")) + if name == "" { + http.NotFound(w, r) + return + } + if u.stateRepo != nil { + items, err := u.stateRepo.ListByProfileName(name) + if err != nil { + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + if len(items) > 0 { + deviceIDs := make([]string, 0, len(items)) + for _, item := range items { + deviceIDs = append(deviceIDs, item.DeviceID) + } + msg := fmt.Sprintf("场景模板 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、")) + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound) + return + } + } + if err := u.preview.DeleteProfileAsset(name); err != nil { + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景模板已删除"), http.StatusFound) } func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { @@ -1688,7 +1835,7 @@ func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { } func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request) { - target := "/ui/plans" + target := "/ui/scene-templates" if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } @@ -1696,7 +1843,206 @@ func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request } func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/plans/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) + http.Redirect(w, r, "/ui/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) +} + +func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Request) { + target := "/ui/scene-templates" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) +} + +func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request) { + target := "/ui/scene-templates/" + url.PathEscape(chi.URLParam(r, "name")) + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) +} + +func (u *UI) pageRecognitionUnits(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("") + data.Title = "识别单元" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + selected := strings.TrimSpace(r.URL.Query().Get("ref")) + if selected == "" && !newMode && len(data.RecognitionUnits) > 0 { + selected = data.RecognitionUnits[0].Ref + } + data.SelectedRecognitionUnit = selected + if newMode { + defaultTemplate := "" + if len(data.AssetProfiles) > 0 { + defaultTemplate = data.AssetProfiles[0].Name + } + data.RecognitionUnit = &service.RecognitionUnitAsset{ + SceneTemplateName: defaultTemplate, + OutputChannel: "cam1", + RTSPPort: "8555", + } + data.RecognitionUnitEditing = true + } else if selected != "" { + if item, err := u.preview.GetRecognitionUnit(selected); err == nil { + data.RecognitionUnit = item + data.RecognitionUnitEditing = editMode + } else if data.Error == "" { + data.Error = err.Error() + } + } + u.render(w, r, "recognition_units", data) +} + +func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + asset := service.RecognitionUnitAsset{ + SceneTemplateName: strings.TrimSpace(r.FormValue("scene_template_name")), + Name: strings.TrimSpace(r.FormValue("name")), + DisplayName: strings.TrimSpace(r.FormValue("display_name")), + SiteName: strings.TrimSpace(r.FormValue("site_name")), + VideoSourceRef: strings.TrimSpace(r.FormValue("video_source_ref")), + OutputChannel: strings.TrimSpace(r.FormValue("output_channel")), + RTSPPort: strings.TrimSpace(r.FormValue("rtsp_port")), + } + originalRef := strings.TrimSpace(r.FormValue("original_ref")) + if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil { + data := u.assetPageData("") + data.Title = "识别单元" + data.Error = err.Error() + data.RecognitionUnit = &asset + data.RecognitionUnitEditing = true + data.SelectedRecognitionUnit = originalRef + u.render(w, r, "recognition_units", data) + return + } + ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name) + http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("识别单元已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound) +} + +func serviceRecognitionUnitRef(profileName string, unitName string) string { + return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) +} + +func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request) { + ref := strings.TrimSpace(r.FormValue("ref")) + if err := u.preview.DeleteRecognitionUnit(ref); err != nil { + http.Redirect(w, r, "/ui/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("识别单元已删除"), http.StatusFound) +} + +func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("") + data.Title = "设备分配" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + u.ensureDevicesLoaded() + data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) + maxUnits := service.DefaultMaxUnitsPerDevice() + if value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("max_units_per_device"))); err == nil && value > 0 { + maxUnits = value + } + data.MaxUnitsPerDevice = maxUnits + if u.preview != nil { + if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil { + data.DeviceAssignmentBoard = board + if payload, marshalErr := json.Marshal(board); marshalErr == nil { + data.DeviceAssignmentBoardJSON = template.JS(payload) + } + } else if data.Error == "" { + data.Error = err.Error() + } + } + u.render(w, r, "device_assignments", data) +} + +func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json"))) + if err != nil { + data := u.assetPageData("") + data.Title = "设备分配" + u.ensureDevicesLoaded() + data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) + data.Error = err.Error() + maxUnits := service.DefaultMaxUnitsPerDevice() + if value, convErr := strconv.Atoi(strings.TrimSpace(r.FormValue("max_units_per_device"))); convErr == nil && value > 0 { + maxUnits = value + } + data.MaxUnitsPerDevice = maxUnits + if u.preview != nil { + if board, buildErr := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); buildErr == nil { + data.DeviceAssignmentBoard = board + if payload, marshalErr := json.Marshal(board); marshalErr == nil { + data.DeviceAssignmentBoardJSON = template.JS(payload) + } + } + } + u.render(w, r, "device_assignments", data) + return + } + if err := u.preview.SaveDeviceAssignmentBoard(filterPreviewDeviceAssignments(assignments)); err != nil { + http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("设备分配已保存"), http.StatusFound) +} + +func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device { + out := make([]*models.Device, 0, len(devices)) + seen := make(map[string]struct{}, len(devices)) + for _, dev := range devices { + if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { + continue + } + deviceID := strings.TrimSpace(dev.DeviceID) + if _, ok := seen[deviceID]; ok { + continue + } + seen[deviceID] = struct{}{} + out = append(out, dev) + } + for len(out) < deviceAssignmentPreviewDeviceCount { + index := len(out) + 1 + deviceID := fmt.Sprintf("%s%02d", deviceAssignmentPreviewDevicePrefix, index) + out = append(out, &models.Device{ + DeviceID: deviceID, + DeviceName: fmt.Sprintf("测试设备 %02d", index), + Online: true, + }) + } + return out +} + +func filterPreviewDeviceAssignments(assignments map[string][]string) map[string][]string { + if len(assignments) == 0 { + return assignments + } + filtered := make(map[string][]string, len(assignments)) + for deviceID, refs := range assignments { + if strings.HasPrefix(strings.TrimSpace(deviceID), deviceAssignmentPreviewDevicePrefix) { + continue + } + filtered[deviceID] = refs + } + return filtered +} + +func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(chi.URLParam(r, "id")) + if err := u.preview.DeleteDeviceAssignment(deviceID); err != nil { + http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("设备分配已删除"), http.StatusFound) } func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) { @@ -1868,19 +2214,45 @@ func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("overlays") + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { if item, err := u.preview.GetOverlayAsset(name); err == nil { data.AssetOverlay = item } else if data.Error == "" { data.Error = err.Error() } - } else if len(data.AssetOverlays) > 0 { + } else if !newMode && len(data.AssetOverlays) > 0 { if item, err := u.preview.GetOverlayAsset(data.AssetOverlays[0].Name); err == nil { data.AssetOverlay = item } else if data.Error == "" { data.Error = err.Error() } } + if newMode { + data.AssetOverlay = &service.ConfigOverlayAsset{ + Name: "", + Description: "", + Raw: map[string]any{ + "name": "", + "description": "", + "instance_overrides": map[string]any{"*": map[string]any{"override": map[string]any{}}}, + }, + } + data.AssetOverlayEditing = true + } else { + data.AssetOverlayEditing = editMode && data.AssetOverlay != nil + } + if data.AssetOverlay != nil { + rawJSON, err := compactJSON(data.AssetOverlay.Raw) + if err == nil { + data.OverlayDraftJSON = rawJSON + } + } u.render(w, r, "asset_overlays", data) } @@ -1900,6 +2272,56 @@ func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "overlays", chi.URLParam(r, "name")) } +func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + _ = r.ParseForm() + name := strings.TrimSpace(r.FormValue("name")) + description := strings.TrimSpace(r.FormValue("description")) + rawText := strings.TrimSpace(r.FormValue("json")) + if rawText == "" { + rawText = "{}" + } + raw := map[string]any{} + if err := json.Unmarshal([]byte(rawText), &raw); err != nil { + data := u.assetPageData("overlays") + data.Title = "基础配置" + data.Error = "调试参数 JSON 格式不正确:" + err.Error() + data.AssetOverlayEditing = true + data.AssetOverlay = &service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw} + data.OverlayDraftJSON = rawText + u.render(w, r, "asset_overlays", data) + return + } + asset := service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw} + if err := u.preview.SaveOverlayAsset(asset, raw); err != nil { + data := u.assetPageData("overlays") + data.Title = "基础配置" + data.Error = err.Error() + data.AssetOverlayEditing = true + data.AssetOverlay = &asset + data.OverlayDraftJSON = rawText + u.render(w, r, "asset_overlays", data) + return + } + http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound) +} + +func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + name := chi.URLParam(r, "name") + if err := u.preview.DeleteOverlayAsset(name); err != nil { + http.Redirect(w, r, "/ui/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已删除"), http.StatusFound) +} + func (u *UI) assetPageData(tab string) PageData { data := PageData{ Title: "基础配置", @@ -1946,6 +2368,16 @@ func (u *UI) assetPageData(tab string) PageData { } else if data.Error == "" { data.Error = listErr.Error() } + if items, listErr := u.preview.ListRecognitionUnits(); listErr == nil { + data.RecognitionUnits = items + } else if data.Error == "" { + data.Error = listErr.Error() + } + if items, listErr := u.preview.ListDeviceAssignments(); listErr == nil { + data.DeviceAssignments = items + } else if data.Error == "" { + data.Error = listErr.Error() + } return data } @@ -2000,19 +2432,21 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf } _ = r.ParseForm() editor := service.ConfigProfileEditor{ - Name: strings.TrimSpace(r.FormValue("profile_name")), - BusinessName: strings.TrimSpace(r.FormValue("business_name")), - Description: strings.TrimSpace(r.FormValue("description")), - SiteName: strings.TrimSpace(r.FormValue("site_name")), - Queue: service.ConfigProfileQueueEditor{ - Size: strings.TrimSpace(r.FormValue("queue_size")), - Strategy: strings.TrimSpace(r.FormValue("queue_strategy")), - }, - Instances: parseProfileInstanceForm(r.Form), + Name: strings.TrimSpace(r.FormValue("profile_name")), + PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")), + BusinessName: strings.TrimSpace(r.FormValue("business_name")), + Description: strings.TrimSpace(r.FormValue("description")), + OverlayName: strings.TrimSpace(r.FormValue("overlay_name")), + SiteName: strings.TrimSpace(r.FormValue("site_name")), + Queue: service.DefaultConfigProfileQueue(), + Instances: parseProfileInstanceForm(r.Form), } if editor.Name == "" && data.AssetProfileEditor != nil { editor.Name = data.AssetProfileEditor.Name } + if editor.PrimaryTemplateName == "" && data.AssetProfileEditor != nil { + editor.PrimaryTemplateName = data.AssetProfileEditor.PrimaryTemplateName + } if editor.DeviceCode == "" && data.AssetProfileEditor != nil { editor.DeviceCode = data.AssetProfileEditor.DeviceCode } @@ -2317,28 +2751,9 @@ func (u *UI) actionDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - _ = r.ParseForm() - req := service.ConfigPreviewRequest{ - Template: strings.TrimSpace(r.FormValue("template")), - Profile: strings.TrimSpace(r.FormValue("profile")), - Overlays: cleanFormList(r.Form["overlay"]), - ConfigID: strings.TrimSpace(r.FormValue("config_id")), - ConfigVersion: strings.TrimSpace(r.FormValue("config_version")), - } - if req.Template == "" { - req.Template = "std_workshop_face_recognition_shoe_alarm" - } - if req.Profile == "" { - req.Profile = "local_3588_test" - } - preview, err := u.preview.Render(req) + preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID) data := u.configPreviewPageData(dev) data.ConfigPreview = preview - data.SelectedTemplate = req.Template - data.SelectedProfile = req.Profile - data.SelectedOverlays = append([]string(nil), req.Overlays...) - data.SelectedConfigID = req.ConfigID - data.SelectedVersion = req.ConfigVersion if err != nil { data.Error = err.Error() } @@ -2424,14 +2839,17 @@ func (u *UI) configPreviewPageData(dev *models.Device) PageData { Title: "配置预览", Device: dev, ConfigSources: sources, - SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", - SelectedProfile: "local_3588_test", - SelectedOverlays: []string{"face_debug"}, - SelectedConfigID: "preview_" + dev.DeviceID, } if err != nil { data.Error = err.Error() } + if dev != nil { + if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { + data.DeviceAssignment = assignment + data.SelectedAssignmentDevice = assignment.DeviceID + data.SelectedProfile = assignment.ProfileName + } + } status, _, statusErr := u.loadConfigStatus(dev) data.ConfigStatus = status if statusErr != nil { @@ -2478,6 +2896,31 @@ func cleanFormList(values []string) []string { return out } +func parseDeviceAssignmentBoardState(raw string) (map[string][]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("设备分配数据为空") + } + var payload struct { + Devices map[string][]string `json:"devices"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, fmt.Errorf("设备分配数据格式错误") + } + if payload.Devices == nil { + return map[string][]string{}, nil + } + out := make(map[string][]string, len(payload.Devices)) + for deviceID, refs := range payload.Devices { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + continue + } + out[deviceID] = cleanFormList(refs) + } + return out, nil +} + func parseAdvancedParams(raw string) map[string]any { raw = strings.TrimSpace(raw) if raw == "" { @@ -2493,6 +2936,22 @@ func parseAdvancedParams(raw string) map[string]any { return out } +func defaultProfileEditorDraft(templates []service.ConfigTemplateAsset) *service.ConfigProfileEditor { + templateName := "std_workshop_face_recognition_shoe_alarm" + if len(templates) > 0 && strings.TrimSpace(templates[0].Name) != "" { + templateName = strings.TrimSpace(templates[0].Name) + } + inst := newProfileInstanceDraft(templateName, "cam1") + return &service.ConfigProfileEditor{ + Name: "", + BusinessName: "", + Description: "", + OverlayName: "", + Queue: service.DefaultConfigProfileQueue(), + Instances: []service.ConfigProfileInstanceEditor{inst}, + } +} + func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) @@ -2500,29 +2959,9 @@ func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - _ = r.ParseForm() data := u.deviceDetailPageData(dev) - req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))} - if req.Profile == "" { - req.Profile = data.SelectedProfile - } - data.SelectedProfile = req.Profile - if req.Profile != "" && len(data.AssetProfiles) > 0 { - for i := range data.AssetProfiles { - if strings.TrimSpace(data.AssetProfiles[i].Name) == req.Profile { - data.AssetProfile = &data.AssetProfiles[i] - data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i]) - break - } - } - } - if req.Profile == "" { - data.Error = "请先选择场景配置" - u.render(w, r, "device", data) - return - } - if data.SelectedTemplate == "" { - data.Error = "所选场景配置缺少可用模板,无法生成下发内容" + if data.DeviceAssignment == nil { + data.Error = "请先到设备分配中为该设备指定识别单元" u.render(w, r, "device", data) return } @@ -2531,8 +2970,7 @@ func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) { u.render(w, r, "device", data) return } - req.Template = data.SelectedTemplate - preview, err := u.preview.Render(req) + preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID) data.ConfigPreview = preview if err != nil { data.Error = err.Error() @@ -2920,32 +3358,25 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData { data := u.deviceOverviewPageData(r, selectedIDs, "") sources, err := u.preview.ListSources() - data.Title = "下发场景配置" + data.Title = "下发设备分配" data.ConfigSources = sources data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs) - profiles, profileErr := u.preview.ListProfileAssets() - data.AssetProfiles = profiles - selectedProfile := strings.TrimSpace(r.URL.Query().Get("profile")) - if selectedProfile == "" { - selectedProfile = "local_3588_test" + assignments, assignErr := u.preview.ListDeviceAssignments() + filteredAssignments := make([]service.DeviceAssignmentAsset, 0, len(assignments)) + selectedSet := make(map[string]struct{}, len(data.SelectedDeviceIDs)) + for _, id := range data.SelectedDeviceIDs { + selectedSet[id] = struct{}{} } - for i := range profiles { - if strings.TrimSpace(profiles[i].Name) == selectedProfile { - data.AssetProfile = &profiles[i] - data.SelectedProfile = profiles[i].Name - data.SelectedTemplate = profileAssetTemplate(&profiles[i]) - break + for _, item := range assignments { + if _, ok := selectedSet[item.DeviceID]; ok { + filteredAssignments = append(filteredAssignments, item) } } - if data.AssetProfile == nil && len(profiles) > 0 { - data.AssetProfile = &profiles[0] - data.SelectedProfile = profiles[0].Name - data.SelectedTemplate = profileAssetTemplate(&profiles[0]) - } + data.DeviceAssignments = filteredAssignments if err != nil { data.Error = err.Error() - } else if profileErr != nil { - data.Error = profileErr.Error() + } else if assignErr != nil { + data.Error = assignErr.Error() } return data } @@ -3060,7 +3491,7 @@ func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action s label := row.Device.DisplayName() switch action { case "reload": - summary := "未取到当前场景配置" + summary := "未取到当前运行配置" if row.ConfigStatus != nil { meta := row.ConfigStatus.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { @@ -3076,7 +3507,7 @@ func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action s } lines = append(lines, label+" -> "+summary) case "rollback": - summary := "未取到可回滚场景配置" + summary := "未取到可回滚运行配置" if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil { meta := row.ConfigStatus.PreviousConfig.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 413c702..f2831d2 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -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} } diff --git a/internal/web/ui/templates/asset_overlays.html b/internal/web/ui/templates/asset_overlays.html index 9f9a6b9..5b0a946 100644 --- a/internal/web/ui/templates/asset_overlays.html +++ b/internal/web/ui/templates/asset_overlays.html @@ -5,6 +5,15 @@

{{icon "overlay"}}调试参数列表

+
+ {{icon "apply"}}新增调试参数 + {{if .AssetOverlay}} + {{icon "edit"}}编辑 +
+ +
+ {{end}} +
@@ -17,7 +26,7 @@ {{range .AssetOverlays}} - + @@ -31,10 +40,20 @@ {{if .AssetOverlay}} -
+
+
-

{{icon "overlay"}}调试参数详情

+

{{icon "overlay"}}{{if .AssetOverlayEditing}}调试参数编辑{{else}}调试参数详情{{end}}{{if .AssetOverlay.Name}} · {{.AssetOverlay.Name}}{{end}}

+
+ {{if .AssetOverlayEditing}} + 编辑模式 + 当前调试参数正在编辑,保存后生效。 + {{else}} + 查看模式 + 当前调试参数为只读展示。 + {{end}} +
+ >{{icon "apply"}}导出为 JSON
-
-
调试参数{{.AssetOverlay.Name}}
-
目标数量{{.AssetOverlay.OverrideTargetNum}}
-
描述{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}
-
作用目标{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
-
路径{{.AssetOverlay.Path}}
+ {{if .AssetOverlayEditing}} +
+ + + +
+
+ + {{icon "close"}}取消 +
+ {{else}} +
+
调试参数{{.AssetOverlay.Name}}
+
目标数量{{.AssetOverlay.OverrideTargetNum}}
+
描述{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}
+
作用目标{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
+
路径{{.AssetOverlay.Path}}
+
+ {{end}}
+{{if not .AssetOverlayEditing}}
{{icon "tech"}}原始 JSON
{{json .AssetOverlay.Raw}}
{{end}} + +{{end}} {{if .Error}}
{{.Error}}
{{end}} {{template "asset_tabs_end" .}} {{end}} diff --git a/internal/web/ui/templates/asset_profiles.html b/internal/web/ui/templates/asset_profiles.html index c53e149..27b6f08 100644 --- a/internal/web/ui/templates/asset_profiles.html +++ b/internal/web/ui/templates/asset_profiles.html @@ -4,6 +4,15 @@

{{icon "profile"}}场景配置列表

+
+ {{icon "apply"}}新建场景 + {{if .SelectedProfile}} + 编辑 +
+ + + {{end}} +
{{.Name}} {{if .Description}}{{.Description}}{{else}}-{{end}} {{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
@@ -12,19 +21,17 @@ - {{range .AssetProfiles}} - + - {{else}} - + {{end}}
场景配置 描述 视频通道队列
{{.Name}} {{if .Description}}{{.Description}}{{else}}-{{end}} {{len .Instances}}{{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{else}}-{{end}}
还没有场景配置
还没有场景配置
@@ -32,32 +39,62 @@
{{if .AssetProfileEditor}} -
+{{if .AssetProfileEditing}} + -
+{{end}} +
-

{{icon "profile"}}场景配置

+

{{icon "profile"}}场景配置{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}

+
+ {{if .AssetProfileEditing}} + 编辑模式 + 正在编辑场景配置 + {{else}} + 查看模式 + 当前内容为只读,点击“编辑”后进入表单模式。 + {{end}} +
+ {{if .AssetProfileEditing}} - {{icon "close"}}取消 + {{icon "close"}}取消 + {{end}} + >{{icon "apply"}}导出为 JSON
+ {{if .AssetProfileEditing}}
- - - - - - + + + + +
+ {{else}} +
+
场景名称{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}
+
业务名称{{if .AssetProfileEditor.BusinessName}}{{.AssetProfileEditor.BusinessName}}{{else}}-{{end}}
+
调试参数{{if .AssetProfileEditor.OverlayName}}{{.AssetProfileEditor.OverlayName}}{{else}}不使用{{end}}
+
站点名{{if .AssetProfileEditor.SiteName}}{{.AssetProfileEditor.SiteName}}{{else}}-{{end}}
+
描述{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}
+
+ {{end}}
@@ -67,7 +104,9 @@
{{len .AssetProfileEditor.Instances}} 路 + {{if .AssetProfileEditing}} + {{end}}
@@ -132,7 +171,9 @@ @@ -142,25 +183,34 @@ {{if .AssetProfileEditor.Instances}} -
+
-

{{icon "device"}}通道详情

-
当前通道的修改会在点击“保存场景配置”后统一生效。
+

{{icon "device"}}通道详情{{if gt (len .AssetProfileEditor.Instances) 0}}{{with index .AssetProfileEditor.Instances .ActiveInstanceIndex}}{{if .Name}} · {{.Name}}{{end}}{{end}}{{end}}

+
+ {{if .AssetProfileEditing}} + 编辑模式 + 当前通道的修改会在点击“保存场景配置”后统一生效。 + {{else}} + 查看模式 + 当前通道详情为只读展示。 + {{end}} +
{{range $i, $inst := .AssetProfileEditor.Instances}} {{$template := index $.AssetTemplateMap $inst.Template}}
+ {{if $.AssetProfileEditing}}
- - + + {{range $slot := $template.Slots.Inputs}}
+ {{else}} +
+
通道名{{$inst.Name}}
+
通道显示名{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}
+
模板{{$inst.Template}}
+ {{range $slot := $template.Slots.Inputs}} +
+ {{$slot.Description}} + {{if inputBindingRef $inst.InputBindings $slot.Name}}{{inputBindingRef $inst.InputBindings $slot.Name}}{{else}}-{{end}} +
+ {{end}} + {{range $slot := $template.Slots.Services}} +
+ {{$slot.Description}} + {{if serviceBindingRef $inst.ServiceBindings $slot.Name}}{{serviceBindingRef $inst.ServiceBindings $slot.Name}}{{else}}-{{end}} +
+ {{end}} + {{range $slot := $template.Slots.Outputs}} +
+ {{$slot.Description}} RTSP 端口 + {{if outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}{{else}}-{{end}} +
+ {{end}} +
+ {{end}}
{{end}}
{{end}} +{{if .AssetProfileEditing}} +{{end}}
{{icon "tech"}}原始 JSON diff --git a/internal/web/ui/templates/asset_templates.html b/internal/web/ui/templates/asset_templates.html index 84b0817..05f6454 100644 --- a/internal/web/ui/templates/asset_templates.html +++ b/internal/web/ui/templates/asset_templates.html @@ -4,10 +4,21 @@

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

-
标准模板应作为基线,复制后再做定制化修改。空白模板仅用于高级场景。
+
标准模板应作为基线,复制后再做定制化修改。
- {{icon "add"}}新建空白模板 + {{if .AssetTemplate}} + {{if .AssetTemplate.ReadOnly}} +
+ + + {{else}} + {{icon "edit"}}编辑 +
+ + + {{end}} + {{end}}
@@ -22,7 +33,7 @@
{{range .AssetTemplates}} - + @@ -36,70 +47,57 @@ -{{if .TemplateCreateMode}} -
-
-
-

{{icon "template"}}{{if eq .TemplateCreateMode "clone"}}复制标准模板{{else}}新建空白模板{{end}}

-
- {{if eq .TemplateCreateMode "clone"}}复制后会保留原有节点和连线结构,再进入可视化编辑。{{else}}空白模板会打开空白画布,不包含任何预置处理链。{{end}} -
-
-
-
- - - - - -
-{{end}} - {{if .AssetTemplate}} -
+
-

{{icon "template"}}模板详情

+

{{icon "template"}}{{if .AssetTemplateEditing}}模板编辑{{else}}模板详情{{end}}{{if .AssetTemplate.Name}} · {{.AssetTemplate.Name}}{{end}}

+
+ {{if .AssetTemplateEditing}} + 编辑模式 + 当前模板基础信息正在编辑,结构修改请进入可视化编辑。 + {{else}} + 查看模式 + 当前模板为只读详情展示。 + {{end}} +
{{icon "assets"}}{{if .AssetTemplate.ReadOnly}}可视化预览{{else}}可视化编辑{{end}} - {{icon "add"}}{{if .AssetTemplate.ReadOnly}}复制标准模板{{else}}复制模板{{end}} - {{if not .AssetTemplate.ReadOnly}} -
- - - {{end}} + >{{icon "apply"}}导出为 JSON
+ {{if .AssetTemplateEditing}} +
+
+ + + +
+
+
来源文件{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}
+
节点数{{.AssetTemplate.NodeCount}}
+
连线数{{.AssetTemplate.EdgeCount}}
+
输入槽位{{if .AssetTemplate.Slots.Inputs}}{{len .AssetTemplate.Slots.Inputs}}{{else}}0{{end}}
+
服务槽位{{if .AssetTemplate.Slots.Services}}{{len .AssetTemplate.Slots.Services}}{{else}}0{{end}}
+
输出槽位{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}
+
路径{{.AssetTemplate.Path}}
+
+
+ + {{icon "close"}}取消 +
+ + {{else}}
模板名 - {{if .AssetTemplate.ReadOnly}} {{.AssetTemplate.Name}} - {{else}} -
- {{.AssetTemplate.Name}} - -
- - - - - - - {{end}}
模板类型{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}
来源文件{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}
@@ -110,23 +108,11 @@
输出槽位{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}
描述 - {{if .AssetTemplate.ReadOnly}} {{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}} - {{else}} -
- {{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}} - -
- - - - - - - {{end}}
路径{{.AssetTemplate.Path}}
+ {{end}}
{{if or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs}} diff --git a/internal/web/ui/templates/assets.html b/internal/web/ui/templates/assets.html index 9211727..3811e92 100644 --- a/internal/web/ui/templates/assets.html +++ b/internal/web/ui/templates/assets.html @@ -61,7 +61,7 @@
{{range .AssetIntegrations}} - + @@ -78,50 +78,77 @@ {{if .AssetIntegration}} -
+
-

{{icon "service"}}{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}

+

{{icon "service"}}{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}{{if .AssetIntegration.Name}} · {{.AssetIntegration.Name}}{{end}}

+
+ {{if .AssetIntegrationEditing}} + 编辑模式 + 当前第三方服务正在编辑,保存后生效。 + {{else}} + 查看模式 + 当前第三方服务为只读展示。 + {{end}} +
+ {{if .AssetIntegrationEditing}}
- + - - + +
- - - - + + + +
- - - - + + + +
- - - - + + + +
- {{if .AssetIntegrationEditing}}
+ {{icon "close"}}取消 +
+ {{else}} +
+
服务名称{{.AssetIntegration.Name}}
+
服务类型{{.AssetIntegration.TypeLabel}}
+
描述{{if .AssetIntegration.Description}}{{.AssetIntegration.Description}}{{else}}-{{end}}
+
启用状态{{if .AssetIntegration.Enabled}}启用{{else}}停用{{end}}
+
对象存储地址{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Endpoint}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{else}}-{{end}}
+
Bucket{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Bucket}}{{.AssetIntegration.ObjectStorage.Bucket}}{{else}}-{{end}}
+
Access Key{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.AccessKey}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{else}}-{{end}}
+
Secret Key{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.SecretKey}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{else}}-{{end}}
+
Token 获取地址{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.GetTokenURL}}{{.AssetIntegration.TokenService.GetTokenURL}}{{else}}-{{end}}
+
认证用户名{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.Username}}{{.AssetIntegration.TokenService.Username}}{{else}}-{{end}}
+
认证租户编码{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.TenantCode}}{{.AssetIntegration.TokenService.TenantCode}}{{else}}-{{end}}
+
消息上报地址{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.PutMessageURL}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{else}}-{{end}}
+
告警用户名{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.Username}}{{.AssetIntegration.AlarmService.Username}}{{else}}-{{end}}
+
告警租户编码{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.TenantCode}}{{.AssetIntegration.AlarmService.TenantCode}}{{else}}-{{end}}
{{end}}
@@ -158,7 +185,7 @@
{{range .AssetVideoSources}} - + @@ -176,41 +203,65 @@ {{if .AssetVideoSource}} -
+
-

{{icon "device"}}{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}

+

{{icon "device"}}{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}{{if .AssetVideoSource.Name}} · {{.AssetVideoSource.Name}}{{end}}

+
+ {{if .AssetVideoSourceEditing}} + 编辑模式 + 当前视频源正在编辑,保存后生效。 + {{else}} + 查看模式 + 当前视频源为只读展示。 + {{end}} +
+ {{if .AssetVideoSourceEditing}}
- + - - + +
- - - - - - - - + + + + + + + +
- {{if .AssetVideoSourceEditing}}
{{icon "close"}}取消
+ {{else}} +
+
视频源名称{{.AssetVideoSource.Name}}
+
类型{{.AssetVideoSource.SourceTypeLabel}}
+
区域{{if .AssetVideoSource.Area}}{{.AssetVideoSource.Area}}{{else}}-{{end}}
+
描述{{if .AssetVideoSource.Description}}{{.AssetVideoSource.Description}}{{else}}-{{end}}
+
输入地址{{if .AssetVideoSource.Config.URL}}{{.AssetVideoSource.Config.URL}}{{else}}-{{end}}
+
标准分辨率{{if .AssetVideoSource.Config.Resolution}}{{.AssetVideoSource.Config.Resolution}}{{else}}-{{end}}
+
像素尺寸{{if .AssetVideoSource.Config.FrameSize}}{{.AssetVideoSource.Config.FrameSize}}{{else}}-{{end}}
+
帧率{{if .AssetVideoSource.Config.FPS}}{{.AssetVideoSource.Config.FPS}}{{else}}-{{end}}
+
视频格式{{if .AssetVideoSource.Config.VideoFormat}}{{.AssetVideoSource.Config.VideoFormat}}{{else}}-{{end}}
+
焦距{{if .AssetVideoSource.Config.FocalLength}}{{.AssetVideoSource.Config.FocalLength}}{{else}}-{{end}}
+
安装高度{{if .AssetVideoSource.Config.MountHeight}}{{.AssetVideoSource.Config.MountHeight}}{{else}}-{{end}}
+
安装角度{{if .AssetVideoSource.Config.MountAngle}}{{.AssetVideoSource.Config.MountAngle}}{{else}}-{{end}}
+
{{end}}
diff --git a/internal/web/ui/templates/config_preview.html b/internal/web/ui/templates/config_preview.html index 81a2dc9..975da97 100644 --- a/internal/web/ui/templates/config_preview.html +++ b/internal/web/ui/templates/config_preview.html @@ -4,49 +4,19 @@
-

{{icon "preview"}}配置预览

+

{{icon "preview"}}设备分配预览

- {{if .ConfigSources.Root}}
{{.ConfigSources.Root}}
{{end}}
-
- - - - -
- 调试参数 -
- {{range .ConfigSources.Overlays}} - - {{end}} -
-
+
+
设备{{.Device.DeviceID}}
+
场景模板{{if .DeviceAssignment}}{{.DeviceAssignment.ProfileName}}{{else}}-{{end}}
+
识别单元{{if .DeviceAssignment}}{{.DeviceAssignment.RecognitionCount}} 路{{else}}0 路{{end}}
- {{if .ConfigPreview}} - - - - {{end}} 返回设备详情
@@ -64,7 +34,7 @@
版本{{index .ConfigPreview.Metadata "config_version"}}
业务名称{{if index .ConfigPreview.Metadata "business_name"}}{{index .ConfigPreview.Metadata "business_name"}}{{else}}-{{end}}
模板{{index .ConfigPreview.Metadata "template"}}
-
业务配置{{index .ConfigPreview.Metadata "profile"}}
+
场景模板{{index .ConfigPreview.Metadata "profile"}}
调试参数{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}
大小{{.ConfigPreview.Size}} bytes
SHA256{{.ConfigPreview.Sha256}}
@@ -77,37 +47,6 @@ {{end}} -{{if and (eq .ResultTitle "应用候选配置结果") .ConfigStatus}} -
-

应用结果摘要

-
-
-
当前运行
-
{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}} / {{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}{{else}}未标记{{end}}
-
{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}
-
sha: {{shortHash .ConfigStatus.Sha256}}
-
-
-
上一份配置
-
{{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}}
-
{{if and .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.PreviousConfig.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}
-
sha: {{if .ConfigStatus.PreviousConfig}}{{shortHash .ConfigStatus.PreviousConfig.Sha256}}{{end}}
-
-
-
候选配置
-
{{if and .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}仍存在{{else}}已清空{{end}}
-
-
-
视觉服务
-
{{if .ConfigStatus.MediaServer.Running}}运行中{{else}}未运行{{end}}
-
-
- {{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)}} -
当前运行与上一份配置回滚点的 config_id/config_version 相同,但文件内容不同,请以调试参数和 sha 为准。
- {{end}} -
-{{end}} - {{if .RawText}}

{{if .ResultTitle}}{{.ResultTitle}}{{else}}执行结果{{end}}

diff --git a/internal/web/ui/templates/dashboard.html b/internal/web/ui/templates/dashboard.html index 57cf4bb..ae2e9b1 100644 --- a/internal/web/ui/templates/dashboard.html +++ b/internal/web/ui/templates/dashboard.html @@ -49,7 +49,7 @@
+ {{if $.AssetProfileEditing}} + {{end}}
{{.Name}} {{if .Description}}{{.Description}}{{else}}-{{end}} {{.NodeCount}}节点 / {{.EdgeCount}}连线
{{.Name}} {{.TypeLabel}} {{if .Description}}{{.Description}}{{else}}-{{end}}
{{.Name}} {{.SourceTypeLabel}} {{if .Area}}{{.Area}}{{else}}-{{end}}
{{.ID}} - {{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"}}启动视频分析服务 diff --git a/internal/web/ui/templates/device.html b/internal/web/ui/templates/device.html index 5a5aa6b..b25b39c 100644 --- a/internal/web/ui/templates/device.html +++ b/internal/web/ui/templates/device.html @@ -5,11 +5,11 @@

设备工作台

-
单设备查看、服务控制、配置应用、模型资源和日志指标都收敛在这里完成。
+
单设备查看、服务控制、设备分配应用、模型资源和日志指标都收敛在这里完成。
@@ -33,7 +33,7 @@ @@ -54,7 +54,7 @@
视频端口{{.Device.MediaPort}}
最后心跳{{ago .Device.LastSeenMs}}
版本{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}
-
当前业务配置{{if and .ConfigStatus .ConfigStatus.Metadata.BusinessName}}{{.ConfigStatus.Metadata.BusinessName}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}
+
当前场景模板{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}
通道名{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}
@@ -71,7 +71,7 @@
配置 ID{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}
配置版本{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}
模板{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}
-
业务配置{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}
+
场景模板{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}
调试参数{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}
配置文件{{.ConfigStatus.ConfigPath}}
配置 SHA{{shortHash .ConfigStatus.Sha256}}
@@ -82,7 +82,7 @@
配置 ID{{if .PersistedConfig.ConfigID}}{{.PersistedConfig.ConfigID}}{{else}}未标记{{end}}
配置版本{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}
模板{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}
-
业务配置{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}
+
场景模板{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}
调试参数{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}
最近下发任务{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}
@@ -129,34 +129,29 @@
-

{{icon "apply"}}场景配置

+

{{icon "apply"}}设备分配

-
-
- -
- {{if .AssetProfile}} + {{if .DeviceAssignment}}
- 查看所选场景配置详情 + 查看当前设备分配详情
-
业务名称{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}
-
关联模板{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}
-
视频通道{{len .AssetProfile.Instances}} 路
-
说明{{if .AssetProfile.Description}}{{.AssetProfile.Description}}{{else}}-{{end}}
+
场景模板{{.DeviceAssignment.ProfileName}}
+
识别单元{{.DeviceAssignment.RecognitionCount}} 路
+
说明{{if .DeviceAssignment.Description}}{{.DeviceAssignment.Description}}{{else}}-{{end}}
+ {{else}} +
+
还没有设备分配
+
请先到“设备分配”页面为这台设备指定识别单元。
+
{{end}} -
- - 预览场景配置 +
+ +
+ 预览当前设备分配
@@ -174,7 +169,7 @@
模型入口通过模型管理页上传到设备
-
人脸库通过场景配置与基础配置维护
+
人脸库通过资源管理与基础配置维护
diff --git a/internal/web/ui/templates/device_assignments.html b/internal/web/ui/templates/device_assignments.html new file mode 100644 index 0000000..855e395 --- /dev/null +++ b/internal/web/ui/templates/device_assignments.html @@ -0,0 +1,372 @@ +{{define "device_assignments"}} +
+
+
+

{{icon "devices"}}设备分配

+
+
+ + {{if .DeviceAssignmentBoard}} +
+
+ 识别单元 + {{.DeviceAssignmentBoard.Stats.TotalUnits}} +
+
+ 设备 + {{.DeviceAssignmentBoard.Stats.TotalDevices}} +
+
+ 已分配 + {{.DeviceAssignmentBoard.Stats.AssignedUnits}} +
+
+ 未分配 + {{.DeviceAssignmentBoard.Stats.UnassignedUnits}} +
+
+ 平均负载 + {{printf "%.1f" .DeviceAssignmentBoard.Stats.AverageLoad}} +
+
+ 满载设备 + {{.DeviceAssignmentBoard.Stats.OverloadedDevices}} +
+
+ + + +
+ +
+ + + +
+
+ +
+ +
+ {{range .DeviceAssignmentBoard.Cards}} +
+
+
+

{{.DeviceName}}

+ {{.DeviceID}} +
+
+ {{.AssignedCount}} / {{.MaxUnits}} + {{.Status}} +
+
+
+ {{range .Units}} + + {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}} + + + {{end}} +
+ {{if lt .AssignedCount .MaxUnits}} +
+ + +
+ {{end}} +
+ {{end}} +
+ +
+
+
+

未分配识别单元

+
+
+
+ {{range .DeviceAssignmentBoard.Unassigned}} + + {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}} + + {{else}} +
已全部分配
+ {{end}} +
+
+ + + + + {{else}} +
暂无可用的设备分配数据
+ {{end}} +
+{{end}} diff --git a/internal/web/ui/templates/device_batch_config.html b/internal/web/ui/templates/device_batch_config.html index 1e967f0..50c5582 100644 --- a/internal/web/ui/templates/device_batch_config.html +++ b/internal/web/ui/templates/device_batch_config.html @@ -27,23 +27,13 @@
-

{{icon "config"}}下发场景配置

-
先选择一份已有场景配置,再为已选设备创建下发任务。
+

{{icon "config"}}下发设备分配

+
按每台设备当前保存的设备分配,批量创建下发任务。
{{range .SelectedDeviceIDs}}{{end}} -
- -
-
@@ -53,50 +43,34 @@
-

{{icon "preview"}}场景配置摘要

+

{{icon "preview"}}设备分配摘要

- {{if .AssetProfile}} -
-
场景配置{{.AssetProfile.Name}}
-
业务名称{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}
-
关联模板{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}
-
视频通道{{len .AssetProfile.Instances}} 路
- {{with index .AssetProfile.Instances 0}} -
首个通道{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}
- {{end}} - {{if .AssetProfile.Description}} -
说明{{.AssetProfile.Description}}
- {{end}} -
- {{if .AssetProfile.Instances}} -
+ {{if .DeviceAssignments}} +
- - - - + + + - {{range .AssetProfile.Instances}} + {{range .DeviceAssignments}} - - - - + + + {{end}}
通道显示名称站点视频源设备场景模板识别单元
{{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}}{{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}}{{if .SiteName}}{{.SiteName}}{{else}}-{{end}}{{if .VideoSourceRef}}{{.VideoSourceRef}}{{else}}-{{end}}{{.DeviceID}}{{if .ProfileName}}{{.ProfileName}}{{else}}-{{end}}{{.RecognitionCount}} 路
- {{end}} {{else}}
-
还没有可用场景配置
-
请先到场景配置中创建配置,再回来下发。
+
还没有可用设备分配
+
请先到设备分配中完成设备与识别单元绑定,再回来下发。
{{end}}
diff --git a/internal/web/ui/templates/device_config.html b/internal/web/ui/templates/device_config.html index 4a7edd3..a7c5fe9 100644 --- a/internal/web/ui/templates/device_config.html +++ b/internal/web/ui/templates/device_config.html @@ -3,7 +3,7 @@

单设备配置已并入设备详情

-
配置查看、服务控制、候选配置应用和回滚,都已经收敛到设备详情工作台。
+
配置查看、服务控制和回滚,都已经收敛到设备详情工作台。
去设备列表
diff --git a/internal/web/ui/templates/device_control.html b/internal/web/ui/templates/device_control.html index b4631ce..6d372ab 100644 --- a/internal/web/ui/templates/device_control.html +++ b/internal/web/ui/templates/device_control.html @@ -38,8 +38,8 @@
当前模板{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}
-
业务配置{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}
-
候选配置{{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}}
+
场景模板{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}
+
待应用配置{{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}}
@@ -56,7 +56,7 @@
- +
diff --git a/internal/web/ui/templates/devices.html b/internal/web/ui/templates/devices.html index f39a921..0e29466 100644 --- a/internal/web/ui/templates/devices.html +++ b/internal/web/ui/templates/devices.html @@ -42,9 +42,9 @@ - - - 下发场景配置 + + + 下发设备分配 清空选择
diff --git a/internal/web/ui/templates/layout.html b/internal/web/ui/templates/layout.html index 7efc941..916ee98 100644 --- a/internal/web/ui/templates/layout.html +++ b/internal/web/ui/templates/layout.html @@ -22,7 +22,9 @@ {{icon "overview"}}总览 {{icon "devices"}}设备 - {{icon "profile"}}场景配置 + {{icon "profile"}}场景模板 + {{icon "device"}}识别单元 + {{icon "apply"}}设备分配 {{icon "assets"}}基础配置 {{icon "task"}}任务