diff --git a/cmd/safesightd/main.go b/cmd/safesightd/main.go index 3df79d1..84f583d 100644 --- a/cmd/safesightd/main.go +++ b/cmd/safesightd/main.go @@ -52,6 +52,9 @@ func main() { } else if imported > 0 { log.Printf("imported %d standard overlays", imported) } + if err := service.EnsureDefaultIntegrations(assetsRepo); err != nil { + log.Printf("ensure default integrations: %v", err) + } standardModelsDir := filepath.Join("models", "standard_models") modelSvc := service.NewModelManagementService(modelsRepo) if err := modelSvc.SyncStandardModelsFromDirectory(standardModelsDir); err != nil { diff --git a/internal/service/auto_config.go b/internal/service/auto_config.go index b7cbb54..984674f 100644 --- a/internal/service/auto_config.go +++ b/internal/service/auto_config.go @@ -488,6 +488,9 @@ func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (* 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 { @@ -616,3 +619,107 @@ func deriveSafeInstanceName(srcName string) string { 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)) + } + } + } +} + +func setDefault(m map[string]any, key string, val any) { + if _, ok := m[key]; !ok { + m[key] = val + } +} diff --git a/internal/service/standard_templates.go b/internal/service/standard_templates.go index aa23691..e3ec935 100644 --- a/internal/service/standard_templates.go +++ b/internal/service/standard_templates.go @@ -141,3 +141,40 @@ func ImportStandardOverlaysFromDir(repo *storage.AssetsRepo, dir string) (int, e } return imported, nil } + +// EnsureDefaultIntegrations creates the default alarm and storage integration +// services if they don't already exist. Call during startup. +func EnsureDefaultIntegrations(repo *storage.AssetsRepo) error { + defaults := []struct { + Name string + ServiceType string + Description string + Enabled bool + BodyJSON string + }{ + { + Name: "alarm", + ServiceType: "alarm_service", + Description: "告警推送 HTTP API", + Enabled: true, + BodyJSON: `{"get_token_url":"http://10.0.0.49:8080/api/getToken","put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}`, + }, + { + Name: "storage", + ServiceType: "object_storage", + Description: "告警截图和视频存储", + Enabled: true, + BodyJSON: `{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}`, + }, + } + for _, item := range defaults { + existing, _ := repo.GetIntegrationService(item.Name) + if existing != nil { + continue + } + if err := repo.SaveIntegrationService(item.Name, item.ServiceType, item.Description, item.Enabled, item.BodyJSON); err != nil { + return err + } + } + return nil +} diff --git a/internal/service/task.go b/internal/service/task.go index a620ff8..8366078 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -121,6 +122,10 @@ func (s *TaskService) ListTasks() []models.Task { items = append(items, snap) } + sort.Slice(items, func(i, j int) bool { + return items[i].CompletedAt > items[j].CompletedAt + }) + return items } diff --git a/internal/web/ui.go b/internal/web/ui.go index 259d820..6aac45e 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -1063,13 +1063,15 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { sourceNames[s.Name] = s.Name } - // Query each device's capabilities from agent to filter available features. + // Query each device's capabilities and active features from agent. deviceAvailableFeatures := map[string][]ConsoleFeature{} + deviceActiveFeatures := map[string]map[string]bool{} if u.agent != nil { for _, dev := range devices { if dev == nil || !dev.Online || dev.DeviceID == "" { continue } + // Get capabilities body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil) if err != nil || code != 200 { continue @@ -1093,24 +1095,19 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { }) } deviceAvailableFeatures[dev.DeviceID] = features - } - } - // Load persisted device features as the source of truth. - // This represents what capabilities the device is configured for, - // not just what the last deployment used. - persistedFeatures := map[string]map[string]bool{} - if u.preview != nil { - for _, dev := range devices { - if dev == nil || dev.DeviceID == "" { - continue - } - if feats, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil && len(feats) > 0 { - m := map[string]bool{} - for _, f := range feats { - m[f] = true + // Get active features from config template + body2, code2, err2 := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil) + if err2 == nil && code2 == 200 { + tmpl := jsonGetStr(body2, "template") + if tmpl != "" { + active := featuresFromTemplate(tmpl) + m := map[string]bool{} + for _, f := range active { + m[f] = true + } + deviceActiveFeatures[dev.DeviceID] = m } - persistedFeatures[dev.DeviceID] = m } } } @@ -1188,7 +1185,10 @@ func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) { } // Determine which features are enabled from persisted device features. - activeKeys := persistedFeatures[dev.DeviceID] + activeKeys := deviceActiveFeatures[dev.DeviceID] + if activeKeys == nil { + activeKeys = map[string]bool{} + } if activeKeys == nil { activeKeys = map[string]bool{} } @@ -1286,12 +1286,6 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { if len(features) == 0 { continue } - // Skip if features haven't changed from what's already saved. - if saved, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil { - if stringSlicesEqual(sortedCopy(features), sortedCopy(saved)) { - continue - } - } var sources []string if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil && assignment != nil { for _, ref := range assignment.RecognitionUnits { @@ -4526,6 +4520,27 @@ func (u *UI) listTemplatesSafe() ([]service.Template, error) { return u.templates.ListTemplates() } +func featuresFromTemplate(templateName string) []string { + var features []string + if strings.Contains(templateName, "face_recognition") { + features = append(features, "face") + } + if strings.Contains(templateName, "shoe") { + features = append(features, "shoe") + } + return features +} + +func jsonGetStr(body []byte, key string) string { + var m map[string]any + if json.Unmarshal(body, &m) == nil { + if s, ok := m[key].(string); ok { + return s + } + } + return "" +} + func cleanFormList(values []string) []string { out := make([]string, 0, len(values)) for _, value := range values { diff --git a/internal/web/ui/templates/assets.html b/internal/web/ui/templates/assets.html index 7e9adc0..a1cba41 100644 --- a/internal/web/ui/templates/assets.html +++ b/internal/web/ui/templates/assets.html @@ -95,7 +95,7 @@ {{if .AssetIntegrationEditing}}