feat: console one-click auto-config pipeline
管控台一键自动配置:勾选检测功能 + 分配视频源 → 自动生成配置并下发 新增: - FeatureRegistry: 功能→模板映射与选择算法 - template_composer: 多模板 DAG 浅合并 - AutoConfigService: 完整流水线 - console.html: 视频源分配交互 修改: - actionConsoleSave: stub → 完整自动配置编排 - pageConsole: 修复功能勾选回显 + 内存指标解析 - main.go: 注入 AutoConfigService - .gitignore: /managerd 只匹配根目录二进制
This commit is contained in:
parent
59de94bfae
commit
25cf456047
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,5 @@
|
||||
# Build outputs
|
||||
managerd
|
||||
/managerd
|
||||
build/
|
||||
bin/
|
||||
dist/
|
||||
|
||||
@ -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)
|
||||
|
||||
50
docs/auto-config-plan.md
Normal file
50
docs/auto-config-plan.md
Normal file
@ -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 预警**
|
||||
502
internal/service/auto_config.go
Normal file
502
internal/service/auto_config.go
Normal file
@ -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]))
|
||||
}
|
||||
176
internal/service/auto_config_test.go
Normal file
176
internal/service/auto_config_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
619
internal/service/template_composer.go
Normal file
619
internal/service/template_composer.go
Normal file
@ -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
|
||||
}
|
||||
203
internal/service/template_composer_test.go
Normal file
203
internal/service/template_composer_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="4" width="18" height="12" rx="1"/><path d="M7 20h10"/><path d="M9 16v4"/><path d="M15 16v4"/></svg>`,
|
||||
@ -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) {
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
<span class="status-dot {{if $cd.Device.Online}}ok{{else}}bad{{end}}"></span>
|
||||
<span class="console-device-name">{{$cd.Device.DisplayName}}</span>
|
||||
<span class="console-device-meta">{{$cd.Device.IP}} | {{len $cd.Channels}}/{{$cd.MaxUnits}} 路</span>
|
||||
<span class="console-cam-add" style="margin-top:0;padding-top:0;font-size:11px">+ 从未分配添加</span>
|
||||
<span class="console-cam-add" style="margin-top:0;padding-top:0;font-size:11px;cursor:pointer" onclick="showUnassignedForDevice('{{$cd.Device.DeviceID}}')">+ 从未分配添加</span>
|
||||
</div>
|
||||
<div class="console-body">
|
||||
<!-- LEFT: Preview tiles in one row -->
|
||||
@ -61,7 +61,7 @@
|
||||
<table class="console-feature-table">
|
||||
{{range $f := $cd.Features}}
|
||||
<tr>
|
||||
<td class="console-feature-check"><input type="checkbox" name="feature_{{$cd.Device.DeviceID}}" value="{{$f.Key}}"{{if $f.Enabled}} checked{{end}}></td>
|
||||
<td class="console-feature-check"><input type="checkbox" name="device_{{$cd.Device.DeviceID}}_feature" value="{{$f.Key}}"{{if $f.Enabled}} checked{{end}}></td>
|
||||
<td class="console-feature-label">{{$f.Label}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@ -83,6 +83,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- hidden: assigned video sources for this device -->
|
||||
{{range $ch := $cd.Channels}}
|
||||
<input type="hidden" name="device_{{$cd.Device.DeviceID}}_source" value="{{$ch.SourceName}}">
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
@ -104,7 +108,7 @@
|
||||
<div class="console-source-card">
|
||||
<div class="console-source-name">{{$s.Name}}</div>
|
||||
<div class="console-source-meta">{{if $s.Area}}{{$s.Area}} · {{end}}{{$s.SourceSummary}}</div>
|
||||
<div class="console-source-assign">+ 分配到设备</div>
|
||||
<div class="console-source-assign" data-source="{{$s.Name}}" onclick="assignSourceToDevice('{{$s.Name}}')">+ 分配到设备</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<span class="muted small">暂无未分配视频源</span>
|
||||
@ -212,6 +216,72 @@
|
||||
|
||||
// Init device view players immediately
|
||||
initVideoPlayers('.preview-tile[data-device-id]');
|
||||
|
||||
// Source assignment
|
||||
window.assignSourceToDevice = function(sourceName) {
|
||||
// Check if a specific device context exists
|
||||
var ctx = this._assignContext;
|
||||
if (ctx && ctx.deviceID) {
|
||||
addSourceToDeviceForm(ctx.deviceID, sourceName);
|
||||
document.querySelector('.source-assign-overlay').remove();
|
||||
return;
|
||||
}
|
||||
showDevicePicker(sourceName);
|
||||
};
|
||||
|
||||
window.showUnassignedForDevice = function(deviceID) {
|
||||
var cards = document.querySelectorAll('.console-source-card');
|
||||
var visible = [];
|
||||
cards.forEach(function(card) {
|
||||
if (card.style.display !== 'none') {
|
||||
var nameEl = card.querySelector('.console-source-name');
|
||||
if (nameEl) visible.push(nameEl.textContent.trim());
|
||||
}
|
||||
});
|
||||
if (visible.length === 0) { alert('暂无未分配视频源'); return; }
|
||||
var html = '<div class="source-assign-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center" onclick="this.remove()"><div style="background:var(--surface);border-radius:8px;padding:20px;min-width:300px" onclick="event.stopPropagation()"><div style="font-size:14px;font-weight:600;margin-bottom:12px">为设备添加视频源</div>';
|
||||
visible.forEach(function(name) {
|
||||
html += '<div style="padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:4px" onmouseover="this.style.background=\'var(--surface-soft)\'" onmouseout="this.style.background=\'transparent\'" onclick="addSourceToDeviceForm(\'' + deviceID + '\', \'' + name + '\');this.closest(\'.source-assign-overlay\').remove()">' + name + '</div>';
|
||||
});
|
||||
html += '<div style="margin-top:12px;text-align:right"><button class="btn" style="font-size:12px" onclick="this.closest(\'.source-assign-overlay\').remove()">取消</button></div></div></div>';
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
document.body.appendChild(div.firstElementChild);
|
||||
};
|
||||
|
||||
function addSourceToDeviceForm(deviceID, sourceName) {
|
||||
var row = document.querySelector('.console-device-row[data-device-id="' + deviceID + '"]');
|
||||
if (!row) return;
|
||||
var existing = row.querySelector('input[name="device_' + deviceID + '_source"][value="' + sourceName + '"]');
|
||||
if (existing) return;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'device_' + deviceID + '_source';
|
||||
input.value = sourceName;
|
||||
row.appendChild(input);
|
||||
// Hide source card
|
||||
var card = document.querySelector('.console-source-assign[data-source="' + sourceName + '"]');
|
||||
if (card) {
|
||||
var sc = card.closest('.console-source-card');
|
||||
if (sc) sc.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showDevicePicker(sourceName) {
|
||||
var rows = document.querySelectorAll('.console-device-row');
|
||||
if (rows.length === 0) { alert('暂无可分配设备'); return; }
|
||||
var html = '<div class="source-assign-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center" onclick="this.remove()"><div style="background:var(--surface);border-radius:8px;padding:20px;min-width:300px;max-width:400px" onclick="event.stopPropagation()"><div style="font-size:14px;font-weight:600;margin-bottom:12px">分配 \'' + sourceName + '\' 到设备</div>';
|
||||
rows.forEach(function(row) {
|
||||
var did = row.getAttribute('data-device-id');
|
||||
var nameEl = row.querySelector('.console-device-name');
|
||||
var dname = nameEl ? nameEl.textContent : did;
|
||||
html += '<div style="padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:4px" onmouseover="this.style.background=\'var(--surface-soft)\'" onmouseout="this.style.background=\'transparent\'" onclick="addSourceToDeviceForm(\'' + did + '\', \'' + sourceName + '\');this.closest(\'.source-assign-overlay\').remove()">' + dname + ' <span style="font-size:11px;color:var(--muted)">' + did + '</span></div>';
|
||||
});
|
||||
html += '<div style="margin-top:12px;text-align:right"><button class="btn" style="font-size:12px" onclick="this.closest(\'.source-assign-overlay\').remove()">取消</button></div></div></div>';
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
document.body.appendChild(div.firstElementChild);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user