From 25cf45604779a7ccf2857512f1dc4e7d14bf234f Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Tue, 12 May 2026 13:26:49 +0800 Subject: [PATCH] feat: console one-click auto-config pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管控台一键自动配置:勾选检测功能 + 分配视频源 → 自动生成配置并下发 新增: - FeatureRegistry: 功能→模板映射与选择算法 - template_composer: 多模板 DAG 浅合并 - AutoConfigService: 完整流水线 - console.html: 视频源分配交互 修改: - actionConsoleSave: stub → 完整自动配置编排 - pageConsole: 修复功能勾选回显 + 内存指标解析 - main.go: 注入 AutoConfigService - .gitignore: /managerd 只匹配根目录二进制 --- .gitignore | 2 +- cmd/managerd/main.go | 5 +- docs/auto-config-plan.md | 50 ++ internal/service/auto_config.go | 502 +++++++++++++++++ internal/service/auto_config_test.go | 176 ++++++ internal/service/template_composer.go | 619 +++++++++++++++++++++ internal/service/template_composer_test.go | 203 +++++++ internal/web/ui.go | 106 +++- internal/web/ui/templates/console.html | 76 ++- 9 files changed, 1726 insertions(+), 13 deletions(-) create mode 100644 docs/auto-config-plan.md create mode 100644 internal/service/auto_config.go create mode 100644 internal/service/auto_config_test.go create mode 100644 internal/service/template_composer.go create mode 100644 internal/service/template_composer_test.go diff --git a/.gitignore b/.gitignore index d4eb0fa..b4c3873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Build outputs -managerd +/managerd build/ bin/ dist/ diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index 4f78115..00dbdb0 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -96,7 +96,8 @@ func main() { http.Redirect(w, r, "/ui", http.StatusFound) }) - ui, err := web.NewUI(discoSvc, regSvc, agentClient, taskSvc, tplSvc, service.NewConfigPreviewService(cfg, assetsRepo)) + previewSvc := service.NewConfigPreviewService(cfg, assetsRepo) + ui, err := web.NewUI(discoSvc, regSvc, agentClient, taskSvc, tplSvc, previewSvc) if err != nil { log.Fatalf("failed to init ui: %v", err) } @@ -105,6 +106,8 @@ func main() { ui.SetDBPath(cfg.DBPathOrDefault()) ui.SetResourcesRepo(resourcesRepo) ui.SetAlarmCollector(alarmCollector) + autoCfgSvc := service.NewAutoConfigService(previewSvc, taskSvc, service.NewFeatureRegistry()) + ui.SetAutoConfig(autoCfgSvc) uiRouter, err := ui.Routes() if err != nil { log.Fatalf("failed to init ui routes: %v", err) diff --git a/docs/auto-config-plan.md b/docs/auto-config-plan.md new file mode 100644 index 0000000..00067fa --- /dev/null +++ b/docs/auto-config-plan.md @@ -0,0 +1,50 @@ +# 管控台一键自动配置 实施计划 + +> 创建日期:2026-05-12 +> 目标:管控台勾选功能 + 分配视频源 → 自动生成配置并下发,零用户输入 + +## 实施步骤 + +| # | 内容 | 状态 | +|---|------|------| +| 1 | `FeatureRegistry` + 模板选择算法 + 测试 | ✅ | +| 2 | `template_composer.go` DAG 合并 + 测试 | ✅ | +| 3 | `auto_config.go` 完整流水线 + 测试 | ✅ | +| 4 | 改造 `actionConsoleSave`,wire `AutoConfigService` 到 `main.go` | ✅ | +| 5 | 增强 `console.html` 表单交互 | ✅ | +| 6 | 集成测试(测试设备 10.0.0.81 端到端) | ✅ | + +## 集成测试结果 + +| 检查项 | 结果 | +|--------|------| +| 模板选择 | `std_workshop_face_recognition_shoe_alarm` ✅ | +| 自动场景模板 | `auto_d12a4719c...` ✅ | +| 识别单元 | `cam1`, `cam2` (名称=视频源名) ✅ | +| RTSP 地址 | `rtsp://10.0.0.49:8554/cam` ✅ | +| HLS 路径 | `./web/hls/cam1/index.m3u8` ✅ | +| 配置下发 | 设备 config 已更新 ✅ | +| Media Server | 运行中,正在处理摄像头画面 ✅ | +| HLS 生成 | `index.m3u8` 已有 TS 片段 ✅ | +| HLS 代理 | `/ui/hls/.../index.m3u8` 正常返回 ✅ | + +## 新增文件 + +- `internal/service/auto_config.go` — FeatureRegistry + AutoConfigService 流水线 +- `internal/service/auto_config_test.go` — 模板选择算法测试 +- `internal/service/template_composer.go` — 多模板 DAG 浅合并 +- `internal/service/template_composer_test.go` — 合并逻辑测试 +- `docs/auto-config-plan.md` — 本文档 + +## 修改文件 + +- `internal/web/ui.go` — UI struct 增加 autoConfig 字段 + actionConsoleSave 重写 +- `internal/web/ui/templates/console.html` — 表单字段 + 视频源分配交互 +- `cmd/managerd/main.go` — 创建并注入 AutoConfigService + +## 核心原则 + +- **全自动派生**:识别单元名称、通道号、HLS/RTSP 路径全部自动生成 = 视频源名 +- **模板组合**:多功能 → 先查已有组合模板 → 没有则浅合并原子模板 +- **中文名自动转安全名**:`deriveSafeInstanceName` 将中文视频源名转为合法 ASCII 标识 +- **暂不做 NPU 预警** diff --git a/internal/service/auto_config.go b/internal/service/auto_config.go new file mode 100644 index 0000000..cef0ede --- /dev/null +++ b/internal/service/auto_config.go @@ -0,0 +1,502 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// FeatureDef describes a detection feature that users can toggle on/off. +type FeatureDef struct { + Key string // unique key, e.g. "face", "shoe" + Label string // human-readable label + Templates []string // template names supporting this feature, ordered by coverage (broadest first) +} + +// FeatureRegistry centralises which templates cover which detection features. +type FeatureRegistry struct { + features map[string]*FeatureDef +} + +// NewFeatureRegistry creates a registry with the built-in feature definitions. +func NewFeatureRegistry() *FeatureRegistry { + defs := []FeatureDef{ + { + Key: "face", + Label: "人脸识别", + Templates: []string{"std_workshop_face_recognition_shoe_alarm", "std_face_recognition_stream"}, + }, + { + Key: "shoe", + Label: "劳保鞋检测", + Templates: []string{"std_workshop_face_recognition_shoe_alarm", "std_workshoe_detection_stream"}, + }, + { + Key: "helmet", + Label: "安全帽检测", + Templates: []string{}, // 模型就绪后再补充 + }, + { + Key: "smoking", + Label: "抽烟检测", + Templates: []string{}, // 模型就绪后再补充 + }, + { + Key: "intrusion", + Label: "区域入侵", + Templates: []string{}, // 模板就绪后再补充 + }, + } + r := &FeatureRegistry{features: make(map[string]*FeatureDef, len(defs))} + for i := range defs { + r.features[defs[i].Key] = &defs[i] + } + return r +} + +// FeatureKeys returns all registered feature keys in a stable order. +func (r *FeatureRegistry) FeatureKeys() []string { + keys := make([]string, 0, len(r.features)) + for k := range r.features { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// Feature returns the definition for a feature key, or nil. +func (r *FeatureRegistry) Feature(key string) *FeatureDef { + return r.features[key] +} + +// AllFeatures returns a copy of all feature definitions sorted by key. +func (r *FeatureRegistry) AllFeatures() []FeatureDef { + out := make([]FeatureDef, 0, len(r.features)) + for _, key := range r.FeatureKeys() { + if f := r.features[key]; f != nil { + out = append(out, *f) + } + } + return out +} + +// ResolveResult describes the outcome of template resolution. +type ResolveResult struct { + TemplateName string `json:"template_name"` // selected template name; may be a composed name when Composed is true + Composed bool `json:"composed"` // true when no single template covers all features and composition is needed + + // internal fields for the template composer (unexported) + composePrimary string `json:"-"` + composeFeatures []string `json:"-"` +} + +// ResolveTemplate picks the best template covering all requested features. +// +// Strategy: +// 1. Single-template: find the intersection of all features' template lists. +// Return the first (highest-coverage) match. +// 2. Composition needed: every feature has at least one template but no single +// template covers all. This case will be handled by the template composer +// later — for now we return the broadest template and set Composed=true. +// 3. Error: one or more features have no templates at all. +func (r *FeatureRegistry) ResolveTemplate(featureKeys []string) (*ResolveResult, error) { + if len(featureKeys) == 0 { + return nil, fmt.Errorf("未选择任何检测功能") + } + + // Deduplicate and sort + seen := map[string]struct{}{} + cleaned := make([]string, 0, len(featureKeys)) + for _, key := range featureKeys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if _, ok := r.features[key]; !ok { + return nil, fmt.Errorf("未知的检测功能: %q", key) + } + cleaned = append(cleaned, key) + } + if len(cleaned) == 0 { + return nil, fmt.Errorf("未选择任何检测功能") + } + sort.Strings(cleaned) + + // Collect template lists for each feature. + lists := make([][]string, 0, len(cleaned)) + for _, key := range cleaned { + def := r.features[key] + if len(def.Templates) == 0 { + return nil, fmt.Errorf("%s(%s)的模板尚未就绪", def.Label, def.Key) + } + lists = append(lists, def.Templates) + } + + // Find intersection. + intersection := intersectTemplateLists(lists) + if len(intersection) > 0 { + // Pick the one with the broadest coverage (first in the list). + return &ResolveResult{ + TemplateName: intersection[0], + Composed: false, + }, nil + } + + // No single template covers all features → composition needed. + // For now, pick the template that covers the most features. + primary := pickBroadestTemplate(lists) + composedName := "auto_" + strings.Join(cleaned, "_") + + return &ResolveResult{ + TemplateName: composedName, + Composed: true, + composePrimary: primary, + composeFeatures: append([]string(nil), cleaned...), + }, nil +} + +// intersectTemplateLists returns templates that appear in every list, preserving +// the order of the first list. +func intersectTemplateLists(lists [][]string) []string { + if len(lists) == 0 { + return nil + } + // Count occurrences across all lists. + counts := map[string]int{} + for _, list := range lists { + lseen := map[string]struct{}{} // dedupe within one list + for _, t := range list { + if _, ok := lseen[t]; ok { + continue + } + lseen[t] = struct{}{} + counts[t]++ + } + } + total := len(lists) + // Return in the order of the first list (which is sorted by coverage). + result := make([]string, 0) + seen := map[string]struct{}{} + for _, t := range lists[0] { + if counts[t] == total { + if _, ok := seen[t]; !ok { + seen[t] = struct{}{} + result = append(result, t) + } + } + } + return result +} + +// pickBroadestTemplate returns the template that appears earliest in the +// first list (assumed to be sorted by coverage breadth). +func pickBroadestTemplate(lists [][]string) string { + if len(lists) == 0 || len(lists[0]) == 0 { + return "" + } + return lists[0][0] +} + +func (r *FeatureRegistry) ValidateFeatureKeys(keys []string) error { + seen := map[string]struct{}{} + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if _, ok := r.features[key]; !ok { + return fmt.Errorf("未知的检测功能: %q", key) + } + } + if len(seen) == 0 { + return fmt.Errorf("未选择任何检测功能") + } + return nil +} + +// ──────────────────────────────────────────────────────────── +// AutoConfigService — one-click configuration pipeline +// ──────────────────────────────────────────────────────────── + +// AutoConfigRequest is a single device's configuration intent from the console. +type AutoConfigRequest struct { + DeviceID string `json:"device_id"` + Features []string `json:"features"` // feature keys: "face", "shoe", ... + SourceNames []string `json:"source_names"` // assigned video source names +} + +// AutoConfigResult reports what happened for a single device. +type AutoConfigResult struct { + DeviceID string `json:"device_id"` + TemplateName string `json:"template_name"` + Composed bool `json:"composed"` + UnitsCreated int `json:"units_created"` + ConfigSHA256 string `json:"config_sha256"` + TaskID string `json:"task_id,omitempty"` + Error string `json:"error,omitempty"` +} + +// AutoConfigService orchestrates the full pipeline from user intent to deployed config. +type AutoConfigService struct { + preview *ConfigPreviewService + tasks *TaskService + registry *FeatureRegistry +} + +// NewAutoConfigService creates a new AutoConfigService. +func NewAutoConfigService(preview *ConfigPreviewService, tasks *TaskService, registry *FeatureRegistry) *AutoConfigService { + return &AutoConfigService{ + preview: preview, + tasks: tasks, + registry: registry, + } +} + +// BuildPipeline executes the full auto-config pipeline for a single device: +// 1. Resolve template from feature selection +// 2. If composed, generate the composed template +// 3. Create/update the scene template (profile) +// 4. Create/update recognition units for each video source +// 5. Create/update the device assignment +// 6. Render the final config +// 7. Create a deploy task +// +// If deploy is false, steps 6-7 are skipped (dry-run mode). +func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*AutoConfigResult, error) { + result := &AutoConfigResult{DeviceID: req.DeviceID} + + // ---- 1. Resolve template ---- + cleanSources := cleanSourceNames(req.SourceNames) + if len(cleanSources) == 0 { + result.Error = "未分配任何视频源" + return result, fmt.Errorf(result.Error) + } + + resolveResult, err := s.registry.ResolveTemplate(req.Features) + if err != nil { + result.Error = err.Error() + return result, err + } + + templateName := resolveResult.TemplateName + result.Composed = resolveResult.Composed + + // ---- 2. Compose template if needed ---- + if resolveResult.Composed { + composed, err := s.composeFromFeatureTemplates(req.Features, resolveResult.composePrimary, resolveResult.composeFeatures) + if err != nil { + result.Error = fmt.Sprintf("模板组合失败: %v", err) + return result, fmt.Errorf(result.Error) + } + body, err := marshalConfigJSON(composed) + if err != nil { + result.Error = fmt.Sprintf("序列化组合模板失败: %v", err) + return result, fmt.Errorf(result.Error) + } + if err := s.preview.SaveTemplateAsset(templateName, "自动组合: "+strings.Join(req.Features, "+"), string(body)); err != nil { + result.Error = fmt.Sprintf("保存组合模板失败: %v", err) + return result, fmt.Errorf(result.Error) + } + } + result.TemplateName = templateName + + // ---- 3. Ensure profile (scene template) ---- + profileName := "auto_" + req.DeviceID + editor := ConfigProfileEditor{ + Name: profileName, + PrimaryTemplateName: templateName, + BusinessName: req.DeviceID + " 自动配置", + Description: "管控台一键生成", + } + + // ---- 4. Create recognition units for each video source ---- + for _, srcName := range cleanSources { + unitName := deriveSafeInstanceName(srcName) + inst := ConfigProfileInstanceEditor{ + Name: unitName, + Template: templateName, + VideoSourceRef: srcName, + DisplayName: srcName, + InputBindings: map[string]InputBindingEditor{ + "video_input_main": {VideoSourceRef: srcName}, + }, + OutputBindings: map[string]OutputBindingEditor{ + "stream_output_main": { + PublishHLSPath: "./web/hls/" + unitName + "/index.m3u8", + PublishRTSPPort: "8555", + PublishRTSPPath: "/live/" + unitName, + ChannelNo: unitName, + }, + }, + } + editor.Instances = append(editor.Instances, inst) + } + + if err := s.preview.SaveProfileEditor(editor); err != nil { + result.Error = fmt.Sprintf("保存场景模板失败: %v", err) + return result, fmt.Errorf(result.Error) + } + + // ---- 4b. Save recognition units via the dedicated API ---- + for _, inst := range editor.Instances { + unit := RecognitionUnitAsset{ + SceneTemplateName: profileName, + Name: inst.Name, + DisplayName: inst.DisplayName, + VideoSourceRef: inst.VideoSourceRef, + OutputChannel: inst.ChannelNo, + RTSPPort: inst.PublishRTSPPort, + } + ref := recognitionUnitRef(profileName, inst.Name) + if err := s.preview.SaveRecognitionUnit(unit, ref); err != nil { + result.Error = fmt.Sprintf("保存识别单元 %s 失败: %v", inst.Name, err) + return result, fmt.Errorf(result.Error) + } + result.UnitsCreated++ + } + + // ---- 5. Device assignment ---- + unitRefs := make([]string, 0, len(cleanSources)) + for _, inst := range editor.Instances { + unitRefs = append(unitRefs, recognitionUnitRef(profileName, inst.Name)) + } + assignment := DeviceAssignmentAsset{ + DeviceID: req.DeviceID, + ProfileName: profileName, + Description: "管控台自动分配", + RecognitionUnits: unitRefs, + } + if err := s.preview.SaveDeviceAssignment(assignment); err != nil { + result.Error = fmt.Sprintf("保存设备分配失败: %v", err) + return result, fmt.Errorf(result.Error) + } + + if !deploy { + preview, err := s.preview.RenderDeviceAssignment(req.DeviceID) + if err != nil { + result.Error = fmt.Sprintf("配置渲染失败: %v", err) + return result, fmt.Errorf(result.Error) + } + result.ConfigSHA256 = preview.Sha256 + return result, nil + } + + // ---- 6 & 7. Render and deploy ---- + preview, err := s.preview.RenderDeviceAssignment(req.DeviceID) + if err != nil { + result.Error = fmt.Sprintf("配置渲染失败: %v", err) + return result, fmt.Errorf(result.Error) + } + result.ConfigSHA256 = preview.Sha256 + + var configDoc any + if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { + result.Error = fmt.Sprintf("配置 JSON 无效: %v", err) + return result, fmt.Errorf(result.Error) + } + + task, err := s.tasks.CreateTask("config_apply", []string{req.DeviceID}, map[string]any{"config": configDoc}) + if err != nil { + result.Error = fmt.Sprintf("创建下发任务失败: %v", err) + return result, fmt.Errorf(result.Error) + } + result.TaskID = task.ID + return result, nil +} + +// BuildPipelineBatch executes the pipeline for multiple devices. +func (s *AutoConfigService) BuildPipelineBatch(requests []AutoConfigRequest, deploy bool) ([]AutoConfigResult, error) { + results := make([]AutoConfigResult, 0, len(requests)) + var firstErr error + for _, req := range requests { + result, err := s.BuildPipeline(req, deploy) + if result != nil { + results = append(results, *result) + } + if err != nil && firstErr == nil { + firstErr = err + } + } + return results, firstErr +} + +// composeFromFeatureTemplates loads templates for each feature and composes them. +func (s *AutoConfigService) composeFromFeatureTemplates(featureKeys []string, primaryName string, allFeatures []string) (map[string]any, error) { + primaryRaw, _, err := s.preview.readAssetJSON("templates", primaryName) + if err != nil { + return nil, fmt.Errorf("load primary template %q: %w", primaryName, err) + } + templates := []map[string]any{primaryRaw} + for _, key := range featureKeys { + if def := s.registry.Feature(key); def != nil { + for _, tplName := range def.Templates { + if tplName == primaryName { + continue + } + raw, _, err := s.preview.readAssetJSON("templates", tplName) + if err != nil { + continue + } + templates = append(templates, raw) + break + } + } + } + if len(templates) <= 1 { + return primaryRaw, nil + } + return ComposeTemplates(templates) +} + +func cleanSourceNames(names []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(names)) + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + sort.Strings(out) + return out +} + +// deriveSafeInstanceName converts a user-facing video source name into a valid +// recognition unit instance name ([A-Za-z0-9_.-]+). If the source name is already +// valid, it's used as-is. Otherwise, a sanitised version is generated. +func deriveSafeInstanceName(srcName string) string { + // If already valid per validateConfigName rules, use as-is. + if safeConfigName.MatchString(srcName) { + return srcName + } + // Build a safe name from the source: keep only ASCII letters, digits, underscores. + var b strings.Builder + for _, r := range srcName { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' { + b.WriteRune(r) + } + } + derived := strings.Trim(b.String(), "-_.") + if derived != "" && safeConfigName.MatchString(derived) { + return derived + } + // Fallback: use a short hash suffix to guarantee uniqueness. + sum := sha256.Sum256([]byte(srcName)) + return "src_" + strings.ToLower(hex.EncodeToString(sum[:4])) +} diff --git a/internal/service/auto_config_test.go b/internal/service/auto_config_test.go new file mode 100644 index 0000000..1f8250b --- /dev/null +++ b/internal/service/auto_config_test.go @@ -0,0 +1,176 @@ +package service + +import ( + "strings" + "testing" +) + +func TestFeatureRegistry_ResolveTemplate_SingleFeature(t *testing.T) { + reg := NewFeatureRegistry() + + result, err := reg.ResolveTemplate([]string{"face"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Composed { + t.Errorf("expected single template, got composed=true") + } + // std_workshop_face_recognition_shoe_alarm is first (broadest coverage) + if result.TemplateName != "std_workshop_face_recognition_shoe_alarm" { + t.Errorf("expected std_workshop_face_recognition_shoe_alarm, got %s", result.TemplateName) + } +} + +func TestFeatureRegistry_ResolveTemplate_TwoFeaturesWithCommonTemplate(t *testing.T) { + reg := NewFeatureRegistry() + + // face + shoe both have std_workshop_face_recognition_shoe_alarm in common + result, err := reg.ResolveTemplate([]string{"face", "shoe"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Composed { + t.Errorf("expected single template, got composed=true") + } + if result.TemplateName != "std_workshop_face_recognition_shoe_alarm" { + t.Errorf("expected std_workshop_face_recognition_shoe_alarm, got %s", result.TemplateName) + } +} + +func TestFeatureRegistry_ResolveTemplate_ComposedNeeded(t *testing.T) { + reg := NewFeatureRegistry() + + // Add a feature with a separate template so intersection is empty. + // We temporarily add intrusion with its own template for testing. + reg.features["intrusion"] = &FeatureDef{ + Key: "intrusion", + Label: "区域入侵", + Templates: []string{"std_intrusion_detection"}, + } + + // face → [std_workshop..., std_face_recog] + // intrusion → [std_intrusion_detection] + // Intersection = empty → Composed = true + result, err := reg.ResolveTemplate([]string{"face", "intrusion"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Composed { + t.Errorf("expected composed=true, got false") + } + if !strings.HasPrefix(result.TemplateName, "auto_") { + t.Errorf("composed template should start with auto_, got %s", result.TemplateName) + } + if result.composePrimary != "std_workshop_face_recognition_shoe_alarm" { + t.Errorf("expected compose primary std_workshop_face_recognition_shoe_alarm, got %s", result.composePrimary) + } +} + +func TestFeatureRegistry_ResolveTemplate_UnavailableFeature(t *testing.T) { + reg := NewFeatureRegistry() + + // helmet has no templates yet + _, err := reg.ResolveTemplate([]string{"helmet"}) + if err == nil { + t.Fatal("expected error for unavailable feature, got nil") + } + if !strings.Contains(err.Error(), "尚未就绪") { + t.Errorf("error should mention 尚未就绪, got: %v", err) + } +} + +func TestFeatureRegistry_ResolveTemplate_UnknownFeature(t *testing.T) { + reg := NewFeatureRegistry() + + _, err := reg.ResolveTemplate([]string{"face", "nonexistent"}) + if err == nil { + t.Fatal("expected error for unknown feature, got nil") + } + if !strings.Contains(err.Error(), "未知") { + t.Errorf("error should mention 未知, got: %v", err) + } +} + +func TestFeatureRegistry_ResolveTemplate_Empty(t *testing.T) { + reg := NewFeatureRegistry() + + _, err := reg.ResolveTemplate(nil) + if err == nil { + t.Fatal("expected error for nil, got nil") + } +} + +func TestFeatureRegistry_ResolveTemplate_Dedup(t *testing.T) { + reg := NewFeatureRegistry() + + // Duplicate keys should be deduplicated + result, err := reg.ResolveTemplate([]string{"face", "face", "shoe"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Composed { + t.Errorf("expected single template, got composed=true") + } +} + +func TestIntersectTemplateLists(t *testing.T) { + tests := []struct { + name string + lists [][]string + expect []string + }{ + { + name: "empty", + lists: nil, + expect: nil, + }, + { + name: "single list returns same list", + lists: [][]string{{"a", "b", "c"}}, + expect: []string{"a", "b", "c"}, + }, + { + name: "partial intersection", + lists: [][]string{{"a", "b", "c"}, {"b", "c", "d"}}, + expect: []string{"b", "c"}, + }, + { + name: "no intersection", + lists: [][]string{{"a", "b"}, {"c", "d"}}, + expect: nil, + }, + { + name: "three lists", + lists: [][]string{{"a", "b", "c"}, {"b", "c"}, {"c", "b", "a"}}, + expect: []string{"b", "c"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := intersectTemplateLists(tt.lists) + if len(got) != len(tt.expect) { + t.Errorf("len = %d, want %d (got %v)", len(got), len(tt.expect), got) + return + } + for i := range got { + if got[i] != tt.expect[i] { + t.Errorf("at %d: got %q, want %q", i, got[i], tt.expect[i]) + } + } + }) + } +} + +func TestFeatureRegistry_AllFeatures(t *testing.T) { + reg := NewFeatureRegistry() + all := reg.AllFeatures() + if len(all) < 3 { + t.Errorf("expected at least 3 features, got %d", len(all)) + } + // Verify sorted by key + for i := 1; i < len(all); i++ { + if all[i].Key < all[i-1].Key { + t.Errorf("features not sorted: %q before %q", all[i-1].Key, all[i].Key) + } + } +} diff --git a/internal/service/template_composer.go b/internal/service/template_composer.go new file mode 100644 index 0000000..003dd5b --- /dev/null +++ b/internal/service/template_composer.go @@ -0,0 +1,619 @@ +package service + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// templateNodeType classifies a node for composition purposes. +type templateNodeType int + +const ( + nodeSource templateNodeType = iota // input_* or role=source + nodePreprocess // type=preprocess + nodeOSD // type=osd + nodePublish // type=publish or role=sink + nodeAlarm // type=alarm + nodeDetection // everything else (AI, tracker, logic_gate, etc.) +) + +// templateDAG holds the parsed structure of a single template. +type templateDAG struct { + name string + executor map[string]any + nodes []map[string]any // original node objects + edges []any // original edge tuples [from, to, meta?] + // classification + nodeByID map[string]map[string]any + nodeClasses map[string]templateNodeType + // adjacency + successors map[string][]string + predecessors map[string][]string +} + +// ComposeTemplates merges multiple template RAWs into a single combined template. +// +// The first template in the list is treated as the "primary" — its infrastructure +// nodes (input, preprocess, publish, osd, alarm) are kept as-is. Detection nodes +// from all templates are combined, with supplementary templates' nodes renamed to +// avoid ID collisions. +// +// Returns the merged template as a map suitable for saving. +func ComposeTemplates(templates []map[string]any) (map[string]any, error) { + if len(templates) == 0 { + return nil, fmt.Errorf("至少需要一个模板") + } + if len(templates) == 1 { + // Nothing to compose — return a deep copy. + return deepCopyMap(templates[0]), nil + } + + dags := make([]*templateDAG, 0, len(templates)) + for i, raw := range templates { + dag, err := parseTemplateDAG(raw) + if err != nil { + return nil, fmt.Errorf("parse template %d: %w", i, err) + } + dags = append(dags, dag) + } + + return composeDAGs(dags) +} + +func parseTemplateDAG(raw map[string]any) (*templateDAG, error) { + name := stringValue(raw["name"]) + if name == "" { + return nil, fmt.Errorf("template name is required") + } + + templateMap, _ := raw["template"].(map[string]any) + if templateMap == nil { + return nil, fmt.Errorf("template %q: missing template body", name) + } + + executor, _ := templateMap["executor"].(map[string]any) + + rawNodes, ok := templateMap["nodes"].([]any) + if !ok || len(rawNodes) == 0 { + return nil, fmt.Errorf("template %q: nodes are required", name) + } + + rawEdges, ok := templateMap["edges"].([]any) + if !ok || len(rawEdges) == 0 { + return nil, fmt.Errorf("template %q: edges are required", name) + } + + dag := &templateDAG{ + name: name, + executor: executor, + nodeByID: make(map[string]map[string]any, len(rawNodes)), + nodeClasses: make(map[string]templateNodeType, len(rawNodes)), + successors: make(map[string][]string), + predecessors: make(map[string][]string), + } + + for _, item := range rawNodes { + node, _ := item.(map[string]any) + if node == nil { + return nil, fmt.Errorf("template %q: node entry must be an object", name) + } + id := stringValue(node["id"]) + if id == "" { + return nil, fmt.Errorf("template %q: node id is required", name) + } + dag.nodes = append(dag.nodes, node) + dag.nodeByID[id] = node + dag.nodeClasses[id] = classifyNode(node) + } + + for _, item := range rawEdges { + edge, _ := item.([]any) + if len(edge) < 2 { + return nil, fmt.Errorf("template %q: edge must have at least 2 elements [from, to]", name) + } + from := stringValue(edge[0]) + to := stringValue(edge[1]) + if from == "" || to == "" { + return nil, fmt.Errorf("template %q: edge from/to cannot be empty", name) + } + if _, ok := dag.nodeByID[from]; !ok { + return nil, fmt.Errorf("template %q: edge references unknown node %q", name, from) + } + if _, ok := dag.nodeByID[to]; !ok { + return nil, fmt.Errorf("template %q: edge references unknown node %q", name, to) + } + dag.edges = append(dag.edges, edge) + dag.successors[from] = append(dag.successors[from], to) + dag.predecessors[to] = append(dag.predecessors[to], from) + } + + return dag, nil +} + +func classifyNode(node map[string]any) templateNodeType { + nodeType := stringValue(node["type"]) + role := stringValue(node["role"]) + + if role == "source" || strings.HasPrefix(strings.ToLower(nodeType), "input_") { + return nodeSource + } + if nodeType == "preprocess" { + return nodePreprocess + } + if nodeType == "osd" { + return nodeOSD + } + if nodeType == "publish" || (role == "sink" && nodeType == "publish") { + return nodePublish + } + if nodeType == "alarm" { + return nodeAlarm + } + return nodeDetection +} + +// composeDAGs merges multiple DAGs into one. +func composeDAGs(dags []*templateDAG) (map[string]any, error) { + primary := dags[0] + supplemental := dags[1:] + + // Re-classify: only the first preprocess after input is "infrastructure". + // Later preprocess nodes (e.g. prepare_publish) are pipeline nodes. + for _, dag := range dags { + fixPreprocessClassification(dag) + } + + mergedNodes := make([]any, 0) + mergedEdges := make([]any, 0) + + // Track which node IDs map to which renamed versions. + // For the primary, IDs stay as-is except detection nodes get a prefix. + // For supplemental templates, all kept nodes get a prefix. + renamed := make(map[string]string) // origID → mergedID + mergedIDs := make(map[string]bool) // prevents collisions + + // Reserve primary node IDs + for id := range primary.nodeByID { + mergedIDs[id] = true + } + + // --- 1. Add ALL nodes from primary template --- + for _, node := range primary.nodes { + id := stringValue(node["id"]) + mergedNodes = append(mergedNodes, deepCopyMap(node)) + renamed[id] = id + } + + // --- 2. Add detection-only nodes from supplemental templates --- + for _, dag := range supplemental { + prefix := sanitizeIDPrefix(dag.name) + "_" + for _, node := range dag.nodes { + id := stringValue(node["id"]) + if isInfraNode(dag.nodeClasses[id]) { + continue // infra nodes come from primary only + } + newID := uniqueID(prefix+id, mergedIDs) + mergedIDs[newID] = true + renamed[dag.name+"::"+id] = newID + + cloned := deepCopyMap(node) + cloned["id"] = newID + mergedNodes = append(mergedNodes, cloned) + } + } + + // --- 3. Rewire edges --- + // Primary edges: keep, but remap if target went through detection nodes. + // We need to track the boundary between detection and infra. + // + // Approach: For the primary, keep all edges as-is. + // For supplemental templates, we need to: + // a. Find the "entry" detection node (first detection after preprocess) + // b. Find the "exit" detection node (last detection before OSD/publish) + // c. Add edges: primary_preprocess → entry, exit → primary_osd + + // --- 3a. Primary edges: keep as-is --- + for _, edge := range primary.edges { + mergedEdges = append(mergedEdges, deepCopyEdge(edge)) + } + + // --- 3b. Supplemental template detection chains --- + // Find the preprocess and osd nodes in the primary. + primaryPrepID := findNodeByClass(primary, nodePreprocess) + primaryOSDID := findNodeByClass(primary, nodeOSD) + + for _, dag := range supplemental { + // Find detection chain in this dag + entryID, exitID := findDetectionChainEnds(dag) + + if entryID == "" { + // No detection nodes — nothing to add. + continue + } + + // Find the preprocess's successor in this dag (the first detection) + prepID := findNodeByClass(dag, nodePreprocess) + if prepID != "" { + for _, succ := range dag.successors[prepID] { + if dag.nodeClasses[succ] == nodeDetection { + entryID = succ + break + } + } + } + + // Find the node before OSD/publish (the last detection) + osdID := findNodeByClass(dag, nodeOSD) + if osdID == "" { + osdID = findNodeByClass(dag, nodePublish) + } + if osdID != "" && len(dag.predecessors[osdID]) > 0 { + exitID = dag.predecessors[osdID][0] + } + + if entryID == "" && exitID == "" { + continue + } + + // Build the detection chain from entry to exit. + chain := buildDetectionChain(dag, entryID, exitID) + if len(chain) == 0 { + continue + } + + // Connect: primary_preprocess → first detection in chain + if primaryPrepID != "" { + newEntry := renamed[dag.name+"::"+chain[0]] + if newEntry == "" { + newEntry = chain[0] // primary detection node, keep as-is + } + // Only add if not already connected + if !hasEdge(mergedEdges, primaryPrepID, newEntry) { + mergedEdges = append(mergedEdges, newEdge(primaryPrepID, newEntry)) + } + } + + // Wire internal chain edges + for i := 0; i < len(chain)-1; i++ { + a := nodeIDInMerged(chain[i], dag.name, renamed) + b := nodeIDInMerged(chain[i+1], dag.name, renamed) + if a != "" && b != "" && !hasEdge(mergedEdges, a, b) { + mergedEdges = append(mergedEdges, newEdge(a, b)) + } + } + + // Connect: last detection in chain → primary_osd + if primaryOSDID != "" { + lastID := nodeIDInMerged(chain[len(chain)-1], dag.name, renamed) + if !hasEdge(mergedEdges, lastID, primaryOSDID) { + mergedEdges = append(mergedEdges, newEdge(lastID, primaryOSDID)) + } + } + } + + // --- 4. Merge alarm rules and OSD labels --- + for _, dag := range supplemental { + mergeAlarmRules(mergedNodes, dag) + mergeOSDLabels(mergedNodes, dag) + } + + // --- 5. Build result --- + executor := map[string]any{ + "batch_size": 2, + "run_budget": estimateRunBudget(dags), + } + + templateBody := map[string]any{ + "executor": executor, + "nodes": mergedNodes, + "edges": mergedEdges, + } + + name := "auto_" + strings.Join(dagNames(dags), "_") + result := map[string]any{ + "name": name, + "description": "自动组合模板: " + strings.Join(dagNames(dags), " + "), + "source": "composed", + "template": templateBody, + } + + return result, nil +} + +func isInfraNode(cls templateNodeType) bool { + switch cls { + case nodeSource, nodePreprocess, nodeOSD, nodePublish, nodeAlarm: + return true + } + return false +} + +func findNodeByClass(dag *templateDAG, cls templateNodeType) string { + for id, c := range dag.nodeClasses { + if c == cls { + return id + } + } + return "" +} + +// findDetectionChainEnds finds the first and last detection nodes in the DAG +// by traversing from preprocess forward and from OSD/publish backward. +func findDetectionChainEnds(dag *templateDAG) (entryID, exitID string) { + prepID := findNodeByClass(dag, nodePreprocess) + if prepID != "" { + for _, succ := range dag.successors[prepID] { + if dag.nodeClasses[succ] == nodeDetection { + entryID = succ + break + } + } + } + + osdID := findNodeByClass(dag, nodeOSD) + if osdID == "" { + osdID = findNodeByClass(dag, nodePublish) + } + if osdID != "" { + for _, pred := range dag.predecessors[osdID] { + if dag.nodeClasses[pred] == nodeDetection { + exitID = pred + break + } + } + } + + return entryID, exitID +} + +// buildDetectionChain walks from entry to exit through detection nodes. +func buildDetectionChain(dag *templateDAG, entryID, exitID string) []string { + if entryID == "" || exitID == "" { + return nil + } + chain := []string{entryID} + if entryID == exitID { + return chain + } + visited := map[string]bool{entryID: true} + current := entryID + for current != exitID { + var next string + for _, succ := range dag.successors[current] { + cls := dag.nodeClasses[succ] + if cls == nodeDetection && !visited[succ] { + next = succ + break + } + } + if next == "" { + // Try the first unvisited successor regardless of type + for _, succ := range dag.successors[current] { + if !visited[succ] { + next = succ + break + } + } + } + if next == "" { + break + } + chain = append(chain, next) + visited[next] = true + if next == exitID { + break + } + current = next + } + return chain +} + +func nodeIDInMerged(origID, dagName string, renamed map[string]string) string { + if v, ok := renamed[dagName+"::"+origID]; ok { + return v + } + if v, ok := renamed[origID]; ok { + return v + } + return origID +} + +func hasEdge(edges []any, from, to string) bool { + for _, e := range edges { + edge, _ := e.([]any) + if len(edge) >= 2 && stringValue(edge[0]) == from && stringValue(edge[1]) == to { + return true + } + } + return false +} + +func newEdge(from, to string) []any { + return []any{from, to} +} + +func deepCopyEdge(edge any) any { + // Deep copy via JSON round-trip for safety. + body, err := json.Marshal(edge) + if err != nil { + // Fallback: shallow copy. + if arr, ok := edge.([]any); ok { + out := make([]any, len(arr)) + copy(out, arr) + return out + } + return edge + } + var out any + json.Unmarshal(body, &out) + return out +} + +func mergeAlarmRules(nodes []any, dag *templateDAG) { + for _, dagNode := range dag.nodes { + if dag.nodeClasses[stringValue(dagNode["id"])] != nodeAlarm { + continue + } + // Find the alarm node in merged nodes. + for _, n := range nodes { + merged, _ := n.(map[string]any) + if merged == nil { + continue + } + if stringValue(merged["type"]) == "alarm" { + dagRules, _ := dagNode["rules"].([]any) + mergedRules, _ := merged["rules"].([]any) + if len(dagRules) > 0 { + // Merge rules, avoiding duplicates by name. + mergedNames := make(map[string]bool) + for _, r := range mergedRules { + if rm, ok := r.(map[string]any); ok { + mergedNames[stringValue(rm["name"])] = true + } + } + for _, r := range dagRules { + if rm, ok := r.(map[string]any); ok { + if !mergedNames[stringValue(rm["name"])] { + mergedRules = append(mergedRules, deepCopyAny(r)) + } + } + } + merged["rules"] = mergedRules + } + break + } + } + } +} + +func mergeOSDLabels(nodes []any, dag *templateDAG) { + for _, dagNode := range dag.nodes { + if dag.nodeClasses[stringValue(dagNode["id"])] != nodeOSD { + continue + } + for _, n := range nodes { + merged, _ := n.(map[string]any) + if merged == nil { + continue + } + if stringValue(merged["type"]) == "osd" { + dagLabels, _ := dagNode["labels"].([]any) + mergedLabels, _ := merged["labels"].([]any) + if len(dagLabels) > 0 { + mergedSet := make(map[string]bool) + for _, l := range mergedLabels { + if s, ok := l.(string); ok { + mergedSet[s] = true + } + } + for _, l := range dagLabels { + if s, ok := l.(string); ok { + if !mergedSet[s] { + mergedLabels = append(mergedLabels, s) + } + } + } + merged["labels"] = mergedLabels + } + break + } + } + } +} + +func estimateRunBudget(dags []*templateDAG) int { + total := 0 + for _, dag := range dags { + if dag.executor != nil { + if b, ok := dag.executor["run_budget"].(float64); ok { + total += int(b) + } + } + } + if total < 4 { + total = 4 + } + if total > 32 { + total = 32 + } + return total +} + +func sanitizeIDPrefix(name string) string { + // Keep alphanumeric and underscore, replace others. + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return strings.Trim(b.String(), "_") +} + +func uniqueID(candidate string, used map[string]bool) string { + if !used[candidate] { + return candidate + } + for i := 2; ; i++ { + alt := fmt.Sprintf("%s_%d", candidate, i) + if !used[alt] { + return alt + } + } +} + +// fixPreprocessClassification marks only the entry preprocess (directly after input) +// as infrastructure. Later preprocess nodes (prepare_publish) are reclassified as +// detection so they stay in the pipeline chain. +func fixPreprocessClassification(dag *templateDAG) { + // Find source nodes. + for id, cls := range dag.nodeClasses { + if cls != nodeSource { + continue + } + // The first preprocess successor of the source is the entry. + for _, succ := range dag.successors[id] { + if dag.nodeClasses[succ] == nodePreprocess { + // Keep as nodePreprocess (infra). + // All OTHER preprocess nodes become detection. + for otherID, otherCls := range dag.nodeClasses { + if otherCls == nodePreprocess && otherID != succ { + dag.nodeClasses[otherID] = nodeDetection + } + } + return + } + } + } +} + +func dagNames(dags []*templateDAG) []string { + out := make([]string, len(dags)) + for i, d := range dags { + out[i] = d.name + } + return out +} + +// sortStringSet returns sorted, deduplicated strings. +func sortStringSet(in []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(in)) + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + sort.Strings(out) + return out +} diff --git a/internal/service/template_composer_test.go b/internal/service/template_composer_test.go new file mode 100644 index 0000000..d7835e9 --- /dev/null +++ b/internal/service/template_composer_test.go @@ -0,0 +1,203 @@ +package service + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func loadJSONTemplate(t *testing.T, name string) map[string]any { + t.Helper() + path := filepath.Join("..", "..", "templates", "standard_templates", name+".json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("parse %s: %v", name, err) + } + return raw +} + +func TestComposeTemplates_SingleTemplateIdentity(t *testing.T) { + face := loadJSONTemplate(t, "std_face_recognition_stream") + result, err := ComposeTemplates([]map[string]any{face}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["name"] == nil { + t.Error("result has no name") + } +} + +func TestComposeTemplates_FacePlusShoe(t *testing.T) { + face := loadJSONTemplate(t, "std_face_recognition_stream") + shoe := loadJSONTemplate(t, "std_workshoe_detection_stream") + + result, err := ComposeTemplates([]map[string]any{face, shoe}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + tmpl, _ := result["template"].(map[string]any) + if tmpl == nil { + t.Fatal("result missing template body") + } + + nodes, _ := tmpl["nodes"].([]any) + edges, _ := tmpl["edges"].([]any) + + t.Logf("composed name: %v", result["name"]) + t.Logf("nodes: %d, edges: %d", len(nodes), len(edges)) + + // Should have more nodes than either input + if len(nodes) < 10 { + t.Errorf("expected at least 10 nodes, got %d", len(nodes)) + } + if len(edges) < 10 { + t.Errorf("expected at least 10 edges, got %d", len(edges)) + } + + // Verify no duplicate node IDs + ids := map[string]bool{} + for _, n := range nodes { + node, _ := n.(map[string]any) + id := stringValue(node["id"]) + if id == "" { + t.Error("node with empty id") + continue + } + if ids[id] { + t.Errorf("duplicate node id: %s", id) + } + ids[id] = true + } + + // Verify edges refer to existing nodes + for _, e := range edges { + edge, _ := e.([]any) + if len(edge) < 2 { + t.Error("edge too short") + continue + } + from := stringValue(edge[0]) + to := stringValue(edge[1]) + if !ids[from] { + t.Errorf("edge from unknown node: %s", from) + } + if !ids[to] { + t.Errorf("edge to unknown node: %s", to) + } + } + + // Verify it can be serialized + body, err := json.MarshalIndent(result, "", " ") + if err != nil { + t.Errorf("marshal composed template: %v", err) + } + t.Logf("composed JSON length: %d bytes", len(body)) + + // Verify key node types exist + hasSource := false + hasPreprocess := false + hasPublish := false + for _, n := range nodes { + node, _ := n.(map[string]any) + t := stringValue(node["type"]) + switch { + case strings.HasPrefix(t, "input_"): + hasSource = true + case t == "preprocess": + hasPreprocess = true + case t == "publish": + hasPublish = true + } + } + if !hasSource { + t.Error("composed template missing source node") + } + if !hasPreprocess { + t.Error("composed template missing preprocess node") + } + if !hasPublish { + t.Error("composed template missing publish node") + } +} + +func TestComposeTemplates_Empty(t *testing.T) { + _, err := ComposeTemplates(nil) + if err == nil { + t.Fatal("expected error for nil input") + } +} + +func TestParseTemplateDAG_Invalid(t *testing.T) { + _, err := parseTemplateDAG(map[string]any{}) + if err == nil { + t.Fatal("expected error for empty raw") + } + + _, err = parseTemplateDAG(map[string]any{ + "name": "test", + "template": map[string]any{ + "nodes": []any{}, + }, + }) + if err == nil { + t.Fatal("expected error for no nodes") + } +} + +func TestClassifyNode(t *testing.T) { + tests := []struct { + node map[string]any + cls templateNodeType + }{ + {map[string]any{"type": "input_rtsp", "role": "source"}, nodeSource}, + {map[string]any{"type": "input_file"}, nodeSource}, + {map[string]any{"type": "preprocess"}, nodePreprocess}, + {map[string]any{"type": "osd"}, nodeOSD}, + {map[string]any{"type": "publish"}, nodePublish}, + {map[string]any{"type": "publish", "role": "sink"}, nodePublish}, + {map[string]any{"type": "alarm"}, nodeAlarm}, + {map[string]any{"type": "ai_yolo"}, nodeDetection}, + {map[string]any{"type": "tracker"}, nodeDetection}, + {map[string]any{"type": "logic_gate"}, nodeDetection}, + } + for _, tt := range tests { + got := classifyNode(tt.node) + if got != tt.cls { + t.Errorf("classifyNode(%v) = %d, want %d", tt.node, got, tt.cls) + } + } +} + +func TestSanitizeIDPrefix(t *testing.T) { + tests := []struct { + in, want string + }{ + {"std_face_recognition_stream", "std_face_recognition_stream"}, + {"my-template-v2", "my_template_v2"}, + {"foo.bar", "foo_bar"}, + {"___abc___", "abc"}, + } + for _, tt := range tests { + got := sanitizeIDPrefix(tt.in) + if got != tt.want { + t.Errorf("sanitizeIDPrefix(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestUniqueID(t *testing.T) { + used := map[string]bool{"foo": true, "foo_2": true} + if got := uniqueID("bar", used); got != "bar" { + t.Errorf("uniqueID(bar) = %s, want bar", got) + } + if got := uniqueID("foo", used); got != "foo_3" { + t.Errorf("uniqueID(foo) = %s, want foo_3", got) + } +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 26ffe5c..fca2a95 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -36,6 +36,7 @@ type UI struct { dbPath string resourcesRepo *storage.ResourcesRepo alarmCollector *service.AlarmCollector + autoConfig *service.AutoConfigService tpl *template.Template } @@ -539,6 +540,13 @@ func (u *UI) SetAlarmCollector(ac *service.AlarmCollector) { u.alarmCollector = ac } +func (u *UI) SetAutoConfig(ac *service.AutoConfigService) { + if u == nil { + return + } + u.autoConfig = ac +} + func tablerIconSVG(name string) string { icons := map[string]string{ "devices": ``, @@ -918,21 +926,35 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { "std_service_test_stream": {}, } - // Collect assigned template names per device - deviceTemplates := map[string]map[string]bool{} + // Collect assigned template names per device. + // First collect scene template (profile) names from recognition units, + // then resolve each profile to its primary_template_name for feature detection. + deviceProfileNames := map[string]map[string]bool{} for _, a := range assignments { - if deviceTemplates[a.DeviceID] == nil { - deviceTemplates[a.DeviceID] = map[string]bool{} + if deviceProfileNames[a.DeviceID] == nil { + deviceProfileNames[a.DeviceID] = map[string]bool{} } for _, ref := range a.RecognitionUnits { for _, u := range units { if u.Ref == ref && u.SceneTemplateName != "" { - deviceTemplates[a.DeviceID][u.SceneTemplateName] = true + deviceProfileNames[a.DeviceID][u.SceneTemplateName] = true break } } } } + // Resolve profile names → actual template names. + deviceTemplates := map[string]map[string]bool{} + for deviceID, profiles := range deviceProfileNames { + deviceTemplates[deviceID] = map[string]bool{} + for profileName := range profiles { + if editor, err := u.preview.GetProfileEditor(profileName); err == nil { + if tpl := editor.RawTemplateName(); tpl != "" { + deviceTemplates[deviceID][tpl] = true + } + } + } + } // Load device metrics (NPU, etc) metrics := map[string]DeviceMetric{} @@ -947,6 +969,10 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { } var m struct { CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"` + Memory struct { + TotalKB float64 `json:"total_kb"` + AvailableKB float64 `json:"available_kb"` + } `json:"memory"` NPU struct { UsagePct float64 `json:"usage_pct"` Cores map[string]float64 `json:"cores"` @@ -964,8 +990,12 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { } } } + memPct := 0.0 + if m.Memory.TotalKB > 0 { + memPct = (m.Memory.TotalKB - m.Memory.AvailableKB) / m.Memory.TotalKB * 100 + } metrics[dev.DeviceID] = DeviceMetric{ - Name: dev.DisplayName(), CPU: m.CPU.UsagePct, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius, + Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: memPct, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius, } } } @@ -1056,8 +1086,68 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - // For now, just acknowledge and redirect back - http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存,即将下发"), http.StatusFound) + if u.autoConfig == nil { + http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound) + return + } + + u.ensureDevicesLoaded() + devices := u.registry.GetDevices() + + // Build request per device from form data. + // Form fields: + // device_{id}_feature = face (one per checked feature) + // device_{id}_source = cam1 (one per assigned video source) + var requests []service.AutoConfigRequest + for _, dev := range devices { + if dev == nil || dev.DeviceID == "" { + continue + } + features := r.Form["device_"+dev.DeviceID+"_feature"] + sources := r.Form["device_"+dev.DeviceID+"_source"] + if len(features) == 0 && len(sources) == 0 { + continue + } + requests = append(requests, service.AutoConfigRequest{ + DeviceID: dev.DeviceID, + Features: features, + SourceNames: sources, + }) + } + + if len(requests) == 0 { + http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("未选择任何检测功能或视频源"), http.StatusFound) + return + } + + results, _ := u.autoConfig.BuildPipelineBatch(requests, true) + + // Collect task IDs for redirect. + taskIDs := make([]string, 0) + var errs []string + for _, res := range results { + if res.TaskID != "" { + taskIDs = append(taskIDs, res.TaskID) + } + if res.Error != "" { + errs = append(errs, res.DeviceID+": "+res.Error) + } + } + + if len(taskIDs) == 1 { + http.Redirect(w, r, "/ui/tasks/"+taskIDs[0], http.StatusFound) + return + } + if len(taskIDs) > 1 { + // Multiple devices: redirect to tasks list. + http.Redirect(w, r, "/ui/tasks?msg="+url.QueryEscape(fmt.Sprintf("已为 %d 台设备创建配置下发任务", len(taskIDs))), http.StatusFound) + return + } + if len(errs) > 0 { + http.Redirect(w, r, "/ui/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound) } func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/ui/templates/console.html b/internal/web/ui/templates/console.html index af155dd..896daf0 100644 --- a/internal/web/ui/templates/console.html +++ b/internal/web/ui/templates/console.html @@ -30,7 +30,7 @@ {{$cd.Device.DisplayName}} - + 从未分配添加 + + 从未分配添加
| + | {{$f.Label}} |