feat: default integrations, inject into config, console live sync, task sort

This commit is contained in:
tian 2026-07-21 18:08:07 +08:00
parent a0f22230cb
commit e284048435
7 changed files with 205 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -95,7 +95,7 @@
</div>
{{if .AssetIntegrationEditing}}
<div class="field-grid">
<label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" autofocus /></label>
<label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" pattern="[A-Za-z0-9_.-]+" title="只允许英文、数字和 ._- " required autofocus /></label>
<label>
<span>服务类型<span class="required-mark">*</span></span>
<select name="type" onchange="toggleIntegrationFields(this.value)">

View File

@ -344,16 +344,18 @@
var confirmHandler = function() {
if (!isDirty()) return;
var summary = '';
var dirtyDevs = [];
document.querySelectorAll('.console-device-row').forEach(function(row) {
var did = row.getAttribute('data-device-id');
var feats = [];
row.querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) { feats.push(cb.value); });
if (feats.sort().join(',') === (initialFeatures[did]||[]).sort().join(',')) return;
dirtyDevs.push(did);
var nameEl = row.querySelector('.console-device-name');
var dname = nameEl ? nameEl.textContent : did;
summary += '<div style="font-weight:600;margin:8px 0 4px">' + dname + '</div>';
row.querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) {
summary += '<div style="font-size:12px;padding-left:12px">☑ ' + cb.value + '</div>';
});
row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) {
summary += '<div style="font-size:12px;padding-left:12px;color:var(--muted)">🎥 ' + h.value + '</div>';
feats.forEach(function(f) {
summary += '<div style="font-size:12px;padding-left:12px">☑ ' + f + '</div>';
});
});
if (!summary) { alert('未选择任何检测功能或视频源'); return; }
@ -374,6 +376,12 @@
overlay.addEventListener('click', function() { overlay.remove(); });
document.body.appendChild(overlay);
document.getElementById('confirm-deploy-btn').addEventListener('click', function() {
if (dirtyDevs.length === 0) { overlay.remove(); return; }
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'dirty_device_ids';
input.value = dirtyDevs.join(',');
document.getElementById('console-form').appendChild(input);
overlay.remove();
document.getElementById('console-form').submit();
}, {once: true});