feat: default integrations, inject into config, console live sync, task sort
This commit is contained in:
parent
a0f22230cb
commit
e284048435
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)">
|
||||
|
||||
@ -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});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user