safesight-control/internal/service/template_composer.go
tian 25cf456047 feat: console one-click auto-config pipeline
管控台一键自动配置:勾选检测功能 + 分配视频源 → 自动生成配置并下发

新增:
- FeatureRegistry: 功能→模板映射与选择算法
- template_composer: 多模板 DAG 浅合并
- AutoConfigService: 完整流水线
- console.html: 视频源分配交互

修改:
- actionConsoleSave: stub → 完整自动配置编排
- pageConsole: 修复功能勾选回显 + 内存指标解析
- main.go: 注入 AutoConfigService
- .gitignore: /managerd 只匹配根目录二进制
2026-05-12 13:26:49 +08:00

620 lines
19 KiB
Go

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
}