735 lines
26 KiB
Go
735 lines
26 KiB
Go
package service
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
"safesight-control/internal/storage"
|
||
)
|
||
|
||
// 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
|
||
// nodeTypeRequirements maps feature key → required media-server node types.
|
||
// If a device lacks any required node type, the feature is unavailable.
|
||
nodeTypeRequirements map[string][]string
|
||
}
|
||
|
||
// 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)),
|
||
nodeTypeRequirements: map[string][]string{
|
||
"face": {"ai_face_recog"}, // face recognition plugin
|
||
"shoe": {"ai_shoe_det"}, // shoe detection plugin
|
||
"helmet": {"ai_yolo"}, // YOLO for helmet (model pending)
|
||
"smoking": {"action_recog"}, // action recognition for smoking
|
||
"intrusion": {"region_event"}, // region-based intrusion
|
||
},
|
||
}
|
||
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.
|
||
// AvailableFeatures returns the subset of registered features that are supported
|
||
// by the given device node types (from /v1/graph-node-types).
|
||
// If nodeTypes is nil/empty, all features are considered available (fallback for
|
||
// old agents that don't expose node types).
|
||
func (r *FeatureRegistry) AvailableFeatures(nodeTypes []string) []FeatureDef {
|
||
all := r.AllFeatures()
|
||
if len(nodeTypes) == 0 {
|
||
return all // no info → assume all available
|
||
}
|
||
typeSet := make(map[string]bool, len(nodeTypes))
|
||
for _, t := range nodeTypes {
|
||
typeSet[t] = true
|
||
}
|
||
out := make([]FeatureDef, 0, len(all))
|
||
for _, f := range all {
|
||
if r.featureAvailable(f.Key, typeSet) {
|
||
out = append(out, f)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (r *FeatureRegistry) featureAvailable(key string, nodeTypes map[string]bool) bool {
|
||
required, ok := r.nodeTypeRequirements[key]
|
||
if !ok || len(required) == 0 {
|
||
return true // no specific plugin requirement → always available
|
||
}
|
||
for _, t := range required {
|
||
if !nodeTypes[t] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
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 most specific template: the one covering the fewest features
|
||
// (i.e. listed by the fewest FeatureDefs), not the broadest.
|
||
// This ensures selecting only "face" doesn't pull in a face+shoe template.
|
||
best := intersection[0]
|
||
bestScore := r.templateFeatureCount(best)
|
||
for _, tpl := range intersection[1:] {
|
||
if score := r.templateFeatureCount(tpl); score < bestScore {
|
||
best = tpl
|
||
bestScore = score
|
||
}
|
||
}
|
||
return &ResolveResult{
|
||
TemplateName: best,
|
||
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]
|
||
}
|
||
|
||
// templateFeatureCount returns how many features list this template.
|
||
// Lower = more specific template.
|
||
func (r *FeatureRegistry) templateFeatureCount(templateName string) int {
|
||
count := 0
|
||
for _, def := range r.features {
|
||
for _, t := range def.Templates {
|
||
if t == templateName {
|
||
count++
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
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
|
||
configVersions *storage.ConfigVersionsRepo
|
||
}
|
||
|
||
// NewAutoConfigService creates a new AutoConfigService.
|
||
func NewAutoConfigService(preview *ConfigPreviewService, tasks *TaskService, registry *FeatureRegistry) *AutoConfigService {
|
||
return &AutoConfigService{
|
||
preview: preview,
|
||
tasks: tasks,
|
||
registry: registry,
|
||
}
|
||
}
|
||
|
||
func (s *AutoConfigService) SetConfigVersionsRepo(repo *storage.ConfigVersionsRepo) {
|
||
s.configVersions = repo
|
||
}
|
||
|
||
// 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
|
||
|
||
if s.configVersions != nil {
|
||
_ = s.configVersions.Save(req.DeviceID, preview.Sha256, preview.JSON)
|
||
}
|
||
|
||
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)
|
||
}
|
||
if configMap, ok := configDoc.(map[string]any); ok {
|
||
injectIntegrationsIntoConfig(configMap, s.preview)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// ---- Device feature persistence ----
|
||
|
||
// GetDeviceFeatures returns the persisted feature keys for a device.
|
||
func (s *ConfigPreviewService) GetDeviceFeatures(deviceID string) ([]string, error) {
|
||
if s == nil || s.assets == nil {
|
||
return nil, nil
|
||
}
|
||
rec, err := s.assets.GetDeviceFeatures(deviceID)
|
||
if err != nil || rec == nil {
|
||
return nil, err
|
||
}
|
||
var features []string
|
||
if err := json.Unmarshal([]byte(rec.FeaturesJSON), &features); err != nil {
|
||
return nil, nil
|
||
}
|
||
return features, nil
|
||
}
|
||
|
||
// SaveDeviceFeatures persists the feature keys for a device.
|
||
func (s *ConfigPreviewService) SaveDeviceFeatures(deviceID string, features []string) error {
|
||
if s == nil || s.assets == nil {
|
||
return fmt.Errorf("asset repository is not configured")
|
||
}
|
||
if len(features) == 0 {
|
||
features = []string{}
|
||
}
|
||
body, err := json.Marshal(features)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.assets.SaveDeviceFeatures(deviceID, string(body))
|
||
}
|
||
|
||
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]))
|
||
}
|
||
|
||
func injectIntegrationsIntoConfig(config map[string]any, preview *ConfigPreviewService) {
|
||
if preview == nil {
|
||
return
|
||
}
|
||
integrations, err := preview.ListIntegrationServices()
|
||
if err != nil {
|
||
return
|
||
}
|
||
var alarmCfg *AlarmServiceConfig
|
||
var storageCfg *ObjectStorageConfig
|
||
for _, item := range integrations {
|
||
if !item.Enabled {
|
||
continue
|
||
}
|
||
switch item.Type {
|
||
case "alarm_service":
|
||
if item.AlarmService != nil {
|
||
alarmCfg = item.AlarmService
|
||
}
|
||
case "object_storage":
|
||
if item.ObjectStorage != nil {
|
||
storageCfg = item.ObjectStorage
|
||
}
|
||
}
|
||
}
|
||
if alarmCfg == nil && storageCfg == nil {
|
||
return
|
||
}
|
||
templates, _ := config["templates"].(map[string]any)
|
||
if templates == nil {
|
||
return
|
||
}
|
||
for _, tpl := range templates {
|
||
tplMap, _ := tpl.(map[string]any)
|
||
if tplMap == nil {
|
||
continue
|
||
}
|
||
nodes, _ := tplMap["nodes"].([]any)
|
||
for _, n := range nodes {
|
||
node, _ := n.(map[string]any)
|
||
if node == nil || node["type"] != "alarm" {
|
||
continue
|
||
}
|
||
actions, _ := node["actions"].(map[string]any)
|
||
if actions == nil {
|
||
continue
|
||
}
|
||
if storageCfg != nil {
|
||
for _, key := range []string{"snapshot", "clip"} {
|
||
action, _ := actions[key].(map[string]any)
|
||
if action == nil {
|
||
action = map[string]any{}
|
||
actions[key] = action
|
||
}
|
||
upload, _ := action["upload"].(map[string]any)
|
||
if upload == nil {
|
||
upload = map[string]any{"type": "minio"}
|
||
action["upload"] = upload
|
||
}
|
||
if storageCfg.Endpoint != "" {
|
||
upload["endpoint"] = storageCfg.Endpoint
|
||
}
|
||
if storageCfg.Bucket != "" {
|
||
upload["bucket"] = storageCfg.Bucket
|
||
}
|
||
if storageCfg.AccessKey != "" {
|
||
upload["access_key"] = storageCfg.AccessKey
|
||
}
|
||
if storageCfg.SecretKey != "" {
|
||
upload["secret_key"] = storageCfg.SecretKey
|
||
}
|
||
}
|
||
}
|
||
if alarmCfg != nil {
|
||
extAPI, _ := actions["external_api"].(map[string]any)
|
||
if extAPI == nil {
|
||
extAPI = map[string]any{"enable": true}
|
||
actions["external_api"] = extAPI
|
||
}
|
||
if alarmCfg.GetTokenURL != "" {
|
||
extAPI["getTokenUrl"] = alarmCfg.GetTokenURL
|
||
}
|
||
if alarmCfg.PutMessageURL != "" {
|
||
extAPI["putMessageUrl"] = alarmCfg.PutMessageURL
|
||
}
|
||
if alarmCfg.TenantCode != "" {
|
||
extAPI["tenantCode"] = alarmCfg.TenantCode
|
||
}
|
||
setDefault(extAPI, "timeout_ms", float64(3000))
|
||
setDefault(extAPI, "include_media_url", true)
|
||
setDefault(extAPI, "token_header", "X-Access-Token")
|
||
setDefault(extAPI, "token_json_path", "responseBody.token")
|
||
setDefault(extAPI, "token_cache_sec", float64(1200))
|
||
}
|
||
// Always add agent alarm reporting (http action to local agent)
|
||
if _, ok := actions["http"]; !ok {
|
||
actions["http"] = map[string]any{
|
||
"enable": true,
|
||
"url": "http://127.0.0.1:9100/v1/alarms/report",
|
||
"method": "POST",
|
||
"timeout_ms": 3000,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func setDefault(m map[string]any, key string, val any) {
|
||
if _, ok := m[key]; !ok {
|
||
m[key] = val
|
||
}
|
||
}
|