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:
tian 2026-05-12 13:26:49 +08:00
parent 59de94bfae
commit 25cf456047
9 changed files with 1726 additions and 13 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
# Build outputs
managerd
/managerd
build/
bin/
dist/

View File

@ -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
View 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 预警**

View 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]))
}

View 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)
}
}
}

View 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
}

View 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)
}
}

View File

@ -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) {

View File

@ -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>