func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("integrations") data.Title = "配置中心" data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" selected := strings.TrimSpace(r.URL.Query().Get("name")) if selected != "" { if item, err := u.preview.GetIntegrationService(selected); err == nil { data.AssetIntegration = item } else if data.Error == "" { data.Error = err.Error() } } else if !newMode && len(data.AssetIntegrations) > 0 { if item, err := u.preview.GetIntegrationService(data.AssetIntegrations[0].Name); err == nil { data.AssetIntegration = item } } if data.AssetIntegration == nil { data.AssetIntegration = &service.ConfigIntegrationServiceAsset{ Type: "object_storage", TypeLabel: "对象存储", Enabled: true, ObjectStorage: &service.ObjectStorageConfig{}, } data.AssetIntegrationEditing = true } else { data.AssetIntegrationEditing = newMode || editMode } if data.AssetIntegration != nil && data.AssetIntegration.Type == "custom" { if configRaw, ok := data.AssetIntegration.Raw["config"]; ok { if s, err := compactJSON(configRaw); err == nil { data.IntegrationConfigDraft = s } } if data.IntegrationConfigDraft == "" { data.IntegrationConfigDraft = "{}" } } u.render(w, r, "assets", data) } func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } _ = r.ParseForm() enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "true") || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "on") serviceType := strings.TrimSpace(r.FormValue("type")) asset := service.ConfigIntegrationServiceAsset{ Name: strings.TrimSpace(r.FormValue("name")), Type: serviceType, Description: strings.TrimSpace(r.FormValue("description")), Enabled: enabled, ObjectStorage: &service.ObjectStorageConfig{}, AlarmService: &service.AlarmServiceConfig{}, } if serviceType == "custom" { configRaw := strings.TrimSpace(r.FormValue("config_json")) if configRaw == "" { configRaw = "{}" } var raw map[string]any if err := json.Unmarshal([]byte(configRaw), &raw); err != nil { http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape("自定义配置 JSON 格式不正确:"+err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound) return } asset.Raw = raw } else { asset.ObjectStorage.Endpoint = strings.TrimSpace(r.FormValue("endpoint")) asset.ObjectStorage.Bucket = strings.TrimSpace(r.FormValue("bucket")) asset.ObjectStorage.AccessKey = strings.TrimSpace(r.FormValue("access_key")) asset.ObjectStorage.SecretKey = strings.TrimSpace(r.FormValue("secret_key")) asset.AlarmService.GetTokenURL = strings.TrimSpace(r.FormValue("get_token_url")) asset.AlarmService.PutMessageURL = strings.TrimSpace(r.FormValue("put_message_url")) asset.AlarmService.TenantCode = strings.TrimSpace(r.FormValue("tenant_code")) } if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil { http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound) return } http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound) } func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } name := chi.URLParam(r, "name") if err := u.preview.DeleteIntegrationService(name); err != nil { http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) return } http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound) } func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("overlays") data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source")) cloneName := strings.TrimSpace(r.URL.Query().Get("clone_name")) if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" && !newMode { if item, err := u.preview.GetOverlayAsset(name); err == nil { data.AssetOverlay = item } else if data.Error == "" { data.Error = err.Error() } } else if !newMode && len(data.AssetOverlays) > 0 { if item, err := u.preview.GetOverlayAsset(data.AssetOverlays[0].Name); err == nil { data.AssetOverlay = item } else if data.Error == "" { data.Error = err.Error() } } if newMode && cloneSource != "" { if item, err := u.preview.GetOverlayAsset(cloneSource); err == nil { rawJSON, err := json.Marshal(item.Raw) if err == nil { doc := map[string]any{} if err := json.Unmarshal(rawJSON, &doc); err == nil { doc["name"] = cloneName doc["description"] = item.Description data.AssetOverlay = &service.ConfigOverlayAsset{ Name: cloneName, Description: item.Description, Raw: doc, } } } } if data.AssetOverlay == nil { newMode = false data.Error = "无法加载源调试参数" } } if newMode { data.AssetOverlayEditing = data.AssetOverlay != nil } else { data.AssetOverlayEditing = editMode && data.AssetOverlay != nil && !data.AssetOverlay.ReadOnly } if data.AssetOverlay != nil { rawJSON, err := compactJSON(data.AssetOverlay.Raw) if err == nil { data.OverlayDraftJSON = rawJSON } } u.render(w, r, "asset_overlays", data) } func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data := u.assetPageData("overlays") item, err := u.preview.GetOverlayAsset(name) if err != nil { http.NotFound(w, r) return } data.AssetOverlay = item u.render(w, r, "asset_overlays", data) } func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "overlays", chi.URLParam(r, "name")) } func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } _ = r.ParseForm() name := strings.TrimSpace(r.FormValue("name")) description := strings.TrimSpace(r.FormValue("description")) rawText := strings.TrimSpace(r.FormValue("json")) if rawText == "" { rawText = "{}" } raw := map[string]any{} if err := json.Unmarshal([]byte(rawText), &raw); err != nil { data := u.assetPageData("overlays") data.Title = "配置中心" data.Error = "调试参数 JSON 格式不正确:" + err.Error() data.AssetOverlayEditing = true data.AssetOverlay = &service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw} data.OverlayDraftJSON = rawText u.render(w, r, "asset_overlays", data) return } asset := service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw} if err := u.preview.SaveOverlayAsset(asset, raw); err != nil { data := u.assetPageData("overlays") data.Title = "配置中心" data.Error = err.Error() data.AssetOverlayEditing = true data.AssetOverlay = &asset data.OverlayDraftJSON = rawText u.render(w, r, "asset_overlays", data) return } http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound) } func (u *UI) actionAssetOverlayClone(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } sourceName := chi.URLParam(r, "name") item, err := u.preview.GetOverlayAsset(sourceName) if err != nil || item == nil { http.NotFound(w, r) return } if !item.ReadOnly { http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound) return } targetName, err := u.nextOverlayCloneName(item.Name) if err != nil { http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) return } cloneSource := url.QueryEscape(sourceName) cloneName := url.QueryEscape(targetName) http.Redirect(w, r, "/ui/assets/overlays?new=1&clone_source="+cloneSource+"&clone_name="+cloneName, http.StatusFound) } func (u *UI) nextOverlayCloneName(sourceName string) (string, error) { base := strings.TrimSpace(sourceName) if base == "" { return "", fmt.Errorf("调试参数名称不能为空") } if strings.HasPrefix(base, "std_") { base = strings.TrimPrefix(base, "std_") } candidate := base + "_copy" if item, err := u.preview.GetOverlayAsset(candidate); err == nil && item != nil { for i := 2; i < 1000; i++ { name := fmt.Sprintf("%s_copy_%d", base, i) item, err := u.preview.GetOverlayAsset(name) if err != nil || item == nil { return name, nil } } return "", fmt.Errorf("无法生成可用的调试参数副本名称") } return candidate, nil } func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return } name := chi.URLParam(r, "name") if err := u.preview.DeleteOverlayAsset(name); err != nil { http.Redirect(w, r, "/ui/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) return } http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已删除"), http.StatusFound) } func (u *UI) assetPageData(tab string) PageData { data := PageData{ Title: "配置中心", AssetTab: tab, } if u.preview == nil { data.Error = "配置中心服务未初始化" return data } sources, err := u.preview.ListSources() data.ConfigSources = sources if err != nil { data.Error = err.Error() } if items, listErr := u.preview.ListTemplateAssets(); listErr == nil { data.AssetTemplates = items data.AssetTemplateMap = make(map[string]service.ConfigTemplateAsset, len(items)) for _, item := range items { data.AssetTemplateMap[item.Name] = item } } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListProfileAssets(); listErr == nil { data.AssetProfiles = items for _, item := range items { data.AssetInstanceCount += len(item.Instances) } } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListOverlayAssets(); listErr == nil { data.AssetOverlays = items } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListVideoSources(); listErr == nil { data.AssetVideoSources = items } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListIntegrationServices(); listErr == nil { data.AssetIntegrations = items } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListRecognitionUnits(); listErr == nil { data.RecognitionUnits = items } else if data.Error == "" { data.Error = listErr.Error() } if items, listErr := u.preview.ListDeviceAssignments(); listErr == nil { data.DeviceAssignments = items } else if data.Error == "" { data.Error = listErr.Error() } return data } func (u *UI) profileEditorPageData(name string) (PageData, error) { data := u.assetPageData("profiles") if u.preview == nil { return data, fmt.Errorf("preview service not initialized") } editor, err := u.preview.GetProfileEditor(name) if err != nil { return data, err } data.AssetProfileEditor = editor data.SelectedProfile = editor.Name if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { data.SelectedTemplate = editor.Instances[0].Template } else { data.SelectedTemplate = "std_workshop_face_recognition_shoe_alarm" } return data, nil } func clampActiveInstanceIndex(count int, preferred int) int { if count <= 0 { return 0 } if preferred < 0 { return 0 } if preferred >= count { return count - 1 } return preferred } func activeInstanceIndexFromValues(values url.Values) int { raw := strings.TrimSpace(values.Get("active_instance")) if raw == "" { return 0 } idx, err := strconv.Atoi(raw) if err != nil { return 0 } return idx } func (u *UI) profileEditorActionData(r *http.Request, name string) (service.ConfigProfileEditor, PageData, error) { data, err := u.profileEditorPageData(name) if err != nil { return service.ConfigProfileEditor{}, data, err } _ = r.ParseForm() editor := service.ConfigProfileEditor{ Name: strings.TrimSpace(r.FormValue("profile_name")), PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")), BusinessName: strings.TrimSpace(r.FormValue("business_name")), Description: strings.TrimSpace(r.FormValue("description")), OverlayName: strings.TrimSpace(r.FormValue("overlay_name")), SiteName: strings.TrimSpace(r.FormValue("site_name")), Queue: service.DefaultConfigProfileQueue(), Instances: parseProfileInstanceForm(r.Form), } if editor.Name == "" && data.AssetProfileEditor != nil { editor.Name = data.AssetProfileEditor.Name } if editor.PrimaryTemplateName == "" && data.AssetProfileEditor != nil { editor.PrimaryTemplateName = data.AssetProfileEditor.PrimaryTemplateName } if editor.DeviceCode == "" && data.AssetProfileEditor != nil { editor.DeviceCode = data.AssetProfileEditor.DeviceCode } if len(editor.Instances) == 0 && data.AssetProfileEditor != nil { editor.Instances = append([]service.ConfigProfileInstanceEditor(nil), data.AssetProfileEditor.Instances...) } data.AssetProfileEditor = &editor data.SelectedProfile = editor.Name data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) return editor, data, nil } func (u *UI) exportAssetJSON(w http.ResponseWriter, r *http.Request, kind string, name string) { if u.preview == nil { http.Error(w, "preview service not initialized", http.StatusInternalServerError) return } body, filename, err := u.preview.ExportAssetJSON(kind, name) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) _, _ = w.Write(body) } func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) { data := PageData{Title: "审计记录"} if u.auditRepo != nil { items, err := u.auditRepo.List() if err != nil { data.Error = err.Error() } else { data.AuditEntries = items } } if len(data.AuditEntries) == 0 && u.tasks != nil { data.Tasks = u.tasks.ListTasks() } u.render(w, r, "audit", data) } func (u *UI) pageSystem(w http.ResponseWriter, r *http.Request) { u.renderSystemPage( w, r, http.StatusOK, strings.TrimSpace(r.URL.Query().Get("msg")), strings.TrimSpace(r.URL.Query().Get("error")), ) } func (u *UI) pageSystemDBBackup(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(u.dbPath) == "" { http.Error(w, "database path is not configured", http.StatusNotFound) return } body, err := os.ReadFile(u.dbPath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } filename := "app-" + time.Now().Format("20060102-150405") + ".db" w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) _, _ = w.Write(body) } func (u *UI) renderSystemPage(w http.ResponseWriter, r *http.Request, status int, message string, errText string) { w.WriteHeader(status) u.render(w, r, "system", PageData{ Title: "系统状态", Devices: u.registry.GetDevices(), DBPath: u.dbPath, Message: message, Error: errText, }) } func (u *UI) actionSystemDBRestore(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(u.dbPath) == "" { http.Error(w, "database path is not configured", http.StatusNotFound) return } if err := r.ParseMultipartForm(50 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } file, _, err := r.FormFile("file") if err != nil { u.renderSystemPage(w, r, http.StatusBadRequest, "", "请先选择数据库备份文件") return } defer file.Close() body, err := io.ReadAll(file) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := os.WriteFile(u.dbPath, body, 0o644); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/ui/system?msg="+urlQueryEscape("数据库恢复完成"), http.StatusFound) } func urlQueryEscape(s string) string { r := strings.NewReplacer("%", "%25", " ", "%20", "+", "%2B", "&", "%26", "=", "%3D", "?", "%3F") return r.Replace(s) } func normalizeConfigName(name string) (string, error) { name = strings.TrimSpace(name) if name == "" { return "", fmt.Errorf("name is required") } if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { return "", fmt.Errorf("name contains invalid path") } if !strings.HasSuffix(strings.ToLower(name), ".json") { name += ".json" } return name, nil } func serviceVideoSourceTypeLabel(v string) string { switch strings.TrimSpace(v) { case "rtsp": return "RTSP" case "rtmp": return "RTMP" case "file": return "文件" case "usb_camera": return "USB 摄像头" default: return strings.TrimSpace(v) } } func compactJSON(v any) (string, error) { body, err := json.Marshal(v) if err != nil { return "", err } return string(body), nil } func validateTemplateGraphDocument(doc map[string]any) error { knownTypes := knownGraphNodeTypes() templateMap, ok := doc["template"].(map[string]any) if !ok { return fmt.Errorf("template must be an object") } nodes, ok := templateMap["nodes"].([]any) if !ok { return fmt.Errorf("template.nodes must be an array") } edges, ok := templateMap["edges"].([]any) if !ok { return fmt.Errorf("template.edges must be an array") } seen := map[string]bool{} for _, item := range nodes { node, ok := item.(map[string]any) if !ok { return fmt.Errorf("template node must be an object") } id := strings.TrimSpace(fmt.Sprint(node["id"])) if id == "" { return fmt.Errorf("template node id is required") } if seen[id] { return fmt.Errorf("duplicate node id: %s", id) } seen[id] = true nodeType := strings.TrimSpace(fmt.Sprint(node["type"])) if nodeType == "" { return fmt.Errorf("template node type is required: %s", id) } if !knownTypes[nodeType] { return fmt.Errorf("unknown node type: %s", nodeType) } } for _, item := range edges { var from, to string if edge, ok := item.([]any); ok { if len(edge) < 2 { return fmt.Errorf("edge must have from and to") } from = strings.TrimSpace(fmt.Sprint(edge[0])) to = strings.TrimSpace(fmt.Sprint(edge[1])) } else if edge, ok := item.(map[string]any); ok { from = strings.TrimSpace(fmt.Sprint(edge["from"])) to = strings.TrimSpace(fmt.Sprint(edge["to"])) } else { return fmt.Errorf("edge must be an array or object") } if from == "" || to == "" { return fmt.Errorf("edge has empty endpoint") } if !seen[from] || !seen[to] { return fmt.Errorf("edge references unknown node: %s -> %s", from, to) } } return nil } func prettyJSON(raw []byte) string { var out bytes.Buffer if err := json.Indent(&out, raw, "", " "); err != nil { return string(raw) } return out.String() } func (u *UI) loadConfigStatus(dev *models.Device) (*ConfigStatusView, string, error) { if u.agent == nil || dev == nil { return nil, "", nil } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil) raw := fmt.Sprintf("GET /v1/config/status -> %d\n%s", code, prettyJSON(body)) if err != nil { dev.Online = false return nil, raw, err } if code < 200 || code >= 300 { dev.Online = false return nil, raw, fmt.Errorf("GET /v1/config/status -> %d", code) } dev.Online = true dev.LastSeenMs = time.Now().UnixMilli() var status ConfigStatusView if err := json.Unmarshal(body, &status); err != nil { return nil, raw, err } if v := strings.TrimSpace(status.Metadata.InstanceName); v != "" { dev.InstanceName = v } return &status, raw, nil } func (u *UI) loadConfigUIData(dev *models.Device) PageData { schemaBody, schemaCode, schemaErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/schema", nil) stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/state", nil) faceBody, faceCode, faceErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/face-gallery", nil) data := PageData{ Title: "高级识别配置", Device: dev, SchemaJSON: fmt.Sprintf("GET /v1/config/ui/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)), StateJSON: fmt.Sprintf("GET /v1/config/ui/state -> %d\n%s", stateCode, prettyJSON(stateBody)), FaceGalleryJSON: fmt.Sprintf("GET /v1/face-gallery -> %d\n%s", faceCode, prettyJSON(faceBody)), RawJSON: strings.TrimSpace(prettyJSON(stateBody)), } if schemaErr != nil { data.Error = schemaErr.Error() } else if stateErr != nil { data.Error = stateErr.Error() } else if faceErr != nil { data.Error = faceErr.Error() } return data } func (u *UI) pageDeviceConfigUI(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } u.render(w, r, "config_ui", u.loadConfigUIData(dev)) } func (u *UI) pageDeviceConfigFriendly(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } u.render(w, r, "config_friendly", PageData{Title: "识别方案配置", Device: dev}) } func (u *UI) pageDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } data := u.configPreviewPageData(dev) u.render(w, r, "config_preview", data) } func (u *UI) actionDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID) data := u.configPreviewPageData(dev) data.ConfigPreview = preview if err != nil { data.Error = err.Error() } u.render(w, r, "config_preview", data) } func (u *UI) actionDeviceConfigCandidate(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } _ = r.ParseForm() raw := strings.TrimSpace(r.FormValue("json")) data := u.configPreviewPageData(dev) if raw == "" { data.Error = "候选配置 JSON 不能为空" u.render(w, r, "config_preview", data) return } if err := json.Unmarshal([]byte(raw), new(any)); err != nil { data.Error = "候选配置 JSON 无效: " + err.Error() u.render(w, r, "config_preview", data) return } data.ConfigPreview = previewResultFromJSON(raw) populateSelectionsFromPreview(&data) body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config/candidate", []byte(raw)) data.Message = fmt.Sprintf("PUT /v1/config/candidate -> %d", code) data.RawText = prettyJSON(body) data.ResultTitle = "候选配置结果" if err != nil { data.Error = err.Error() } u.render(w, r, "config_preview", data) } func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } returnTo := strings.TrimSpace(r.FormValue("return_to")) var data PageData if returnTo == "control" || returnTo == "config" { data = u.deviceDetailPageData(dev) } else { data = u.configPreviewPageData(dev) } raw := strings.TrimSpace(r.FormValue("json")) if raw != "" { data.ConfigPreview = previewResultFromJSON(raw) populateSelectionsFromPreview(&data) } body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/candidate/apply", []byte(`{}`)) data.Message = fmt.Sprintf("POST /v1/config/candidate/apply -> %d", code) data.RawText = prettyJSON(body) data.ResultTitle = "应用候选配置结果" if err != nil { data.Error = err.Error() } else { status, _, statusErr := u.loadConfigStatus(dev) data.ConfigStatus = status if statusErr != nil { data.ConfigStatusErr = statusErr.Error() } else { data.ConfigStatusErr = "" } } if returnTo == "control" || returnTo == "config" { u.render(w, r, "device", data) return } u.render(w, r, "config_preview", data) } func (u *UI) configPreviewPageData(dev *models.Device) PageData { sources, err := u.preview.ListSources() data := PageData{ Title: "配置预览", Device: dev, ConfigSources: sources, } if err != nil { data.Error = err.Error() } if dev != nil { if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { data.DeviceAssignment = assignment data.SelectedAssignmentDevice = assignment.DeviceID data.SelectedProfile = assignment.ProfileName } } status, _, statusErr := u.loadConfigStatus(dev) data.ConfigStatus = status if statusErr != nil { data.ConfigStatusErr = statusErr.Error() } return data } func (u *UI) deviceControlPageData(dev *models.Device) PageData { data := PageData{ Title: "设备控制", Device: dev, } status, raw, statusErr := u.loadConfigStatus(dev) data.ConfigStatus = status data.ConfigStatusText = raw if statusErr != nil { data.ConfigStatusErr = statusErr.Error() } return data } func (u *UI) deviceConfigWorkspacePageData(dev *models.Device) PageData { data := u.deviceControlPageData(dev) data.Title = "配置管理" return data } func (u *UI) listTemplatesSafe() ([]service.Template, error) { if u.templates == nil { return nil, nil } return u.templates.ListTemplates() } func cleanFormList(values []string) []string { out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { out = append(out, value) } } return out } func parseDeviceAssignmentBoardState(raw string) (map[string][]string, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, fmt.Errorf("设备分配数据为空") } var payload struct { Devices map[string][]string `json:"devices"` } if err := json.Unmarshal([]byte(raw), &payload); err != nil { return nil, fmt.Errorf("设备分配数据格式错误") } if payload.Devices == nil { return map[string][]string{}, nil } out := make(map[string][]string, len(payload.Devices)) for deviceID, refs := range payload.Devices { deviceID = strings.TrimSpace(deviceID) if deviceID == "" { continue } out[deviceID] = cleanFormList(refs) } return out, nil } func parseAdvancedParams(raw string) map[string]any { raw = strings.TrimSpace(raw) if raw == "" { return nil } var out map[string]any if err := json.Unmarshal([]byte(raw), &out); err != nil { return map[string]any{} } if len(out) == 0 { return nil } return out } func defaultProfileEditorDraft(templates []service.ConfigTemplateAsset) *service.ConfigProfileEditor { templateName := "std_workshop_face_recognition_shoe_alarm" if len(templates) > 0 && strings.TrimSpace(templates[0].Name) != "" { templateName = strings.TrimSpace(templates[0].Name) } inst := newProfileInstanceDraft(templateName, "cam1") return &service.ConfigProfileEditor{ Name: "", BusinessName: "", Description: "", OverlayName: "", Queue: service.DefaultConfigProfileQueue(), Instances: []service.ConfigProfileInstanceEditor{inst}, } } func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } data := u.deviceDetailPageData(dev) if data.DeviceAssignment == nil { data.Error = "请先到通道部署中为该设备指定视频通道" u.render(w, r, "device", data) return } if u.tasks == nil { data.Error = "task service not initialized" u.render(w, r, "device", data) return } preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID) data.ConfigPreview = preview if err != nil { data.Error = err.Error() u.render(w, r, "device", data) return } var configDoc any if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { data.Error = "生成配置 JSON 无效: " + err.Error() u.render(w, r, "device", data) return } task, err := u.tasks.CreateTask("config_apply", []string{dev.DeviceID}, map[string]any{"config": configDoc}) if err != nil { data.Error = err.Error() u.render(w, r, "device", data) return } http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) } func nextProfileInstanceName(instances []service.ConfigProfileInstanceEditor) string { used := make(map[string]struct{}, len(instances)) for _, inst := range instances { name := strings.TrimSpace(inst.Name) if name == "" { continue } used[name] = struct{}{} } for i := 1; ; i++ { candidate := fmt.Sprintf("cam%d", i) if _, ok := used[candidate]; !ok { return candidate } } } func newProfileInstanceDraft(templateName string, channelName string) service.ConfigProfileInstanceEditor { outputs := map[string]service.OutputBindingEditor{ "stream_output_main": { PublishHLSPath: "./web/hls/" + channelName + "/index.m3u8", PublishRTSPPort: "8555", PublishRTSPPath: "/live/" + channelName, ChannelNo: channelName, }, } return service.ConfigProfileInstanceEditor{ Name: channelName, Template: templateName, PublishHLSPath: outputs["stream_output_main"].PublishHLSPath, PublishRTSPPort: outputs["stream_output_main"].PublishRTSPPort, PublishRTSPPath: outputs["stream_output_main"].PublishRTSPPath, ChannelNo: outputs["stream_output_main"].ChannelNo, OutputBindings: outputs, } } func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEditor { indices := make([]int, 0) seen := map[int]struct{}{} for key := range form { if !strings.HasPrefix(key, "instances[") { continue } rest := strings.TrimPrefix(key, "instances[") end := strings.Index(rest, "]") if end <= 0 { continue } idx, err := strconv.Atoi(rest[:end]) if err != nil { continue } if _, ok := seen[idx]; ok { continue } seen[idx] = struct{}{} indices = append(indices, idx) } sort.Ints(indices) out := make([]service.ConfigProfileInstanceEditor, 0, len(indices)) for _, idx := range indices { prefix := fmt.Sprintf("instances[%d].", idx) inputBindings := parseInputBindingForm(form, prefix) serviceBindings := parseServiceBindingForm(form, prefix) outputBindings := parseOutputBindingForm(form, prefix) inst := service.ConfigProfileInstanceEditor{ Name: strings.TrimSpace(form.Get(prefix + "name")), Template: strings.TrimSpace(form.Get(prefix + "template")), VideoSourceRef: firstString(inputBindingValue(inputBindings, "video_input_main"), strings.TrimSpace(form.Get(prefix+"video_source_ref"))), DisplayName: strings.TrimSpace(form.Get(prefix + "display_name")), PublishHLSPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_hls_path"), strings.TrimSpace(form.Get(prefix+"publish_hls_path"))), PublishRTSPPort: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_port"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_port"))), PublishRTSPPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_path"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_path"))), ChannelNo: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "channel_no"), strings.TrimSpace(form.Get(prefix+"channel_no"))), InputBindings: inputBindings, ServiceBindings: serviceBindings, OutputBindings: outputBindings, AdvancedParams: parseAdvancedParams(strings.TrimSpace(form.Get(prefix + "advanced_params"))), Delete: strings.TrimSpace(form.Get(prefix+"delete")) == "1", } if inst.Name != "" || inst.VideoSourceRef != "" || len(inst.ServiceBindings) > 0 || len(inst.OutputBindings) > 0 || inst.Delete { out = append(out, inst) } } if strings.TrimSpace(form.Get("add_instance")) == "1" { templateName := "std_workshop_face_recognition_shoe_alarm" if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" { templateName = strings.TrimSpace(out[0].Template) } out = append(out, newProfileInstanceDraft(templateName, nextProfileInstanceName(out))) } if len(out) > 0 { fallbackTemplate := strings.TrimSpace(out[0].Template) if fallbackTemplate == "" { fallbackTemplate = "std_workshop_face_recognition_shoe_alarm" } for i := range out { if strings.TrimSpace(out[i].Template) == "" { out[i].Template = fallbackTemplate } } } return out } func parseInputBindingForm(form url.Values, prefix string) map[string]service.InputBindingEditor { bindings := map[string]service.InputBindingEditor{} for key := range form { if !strings.HasPrefix(key, prefix+"input_bindings.") || !strings.HasSuffix(key, ".video_source_ref") { continue } slot := strings.TrimPrefix(key, prefix+"input_bindings.") slot = strings.TrimSuffix(slot, ".video_source_ref") slot = strings.TrimSpace(slot) if slot == "" { continue } value := strings.TrimSpace(form.Get(key)) if value == "" { continue } bindings[slot] = service.InputBindingEditor{VideoSourceRef: value} } if len(bindings) == 0 { return nil } return bindings } func parseServiceBindingForm(form url.Values, prefix string) map[string]service.ServiceBindingEditor { bindings := map[string]service.ServiceBindingEditor{} for key := range form { if !strings.HasPrefix(key, prefix+"service_bindings.") || !strings.HasSuffix(key, ".service_ref") { continue } slot := strings.TrimPrefix(key, prefix+"service_bindings.") slot = strings.TrimSuffix(slot, ".service_ref") slot = strings.TrimSpace(slot) if slot == "" { continue } value := strings.TrimSpace(form.Get(key)) if value == "" { continue } bindings[slot] = service.ServiceBindingEditor{ServiceRef: value} } if len(bindings) == 0 { return nil } return bindings } func parseOutputBindingForm(form url.Values, prefix string) map[string]service.OutputBindingEditor { bindings := map[string]service.OutputBindingEditor{} for key := range form { if !strings.HasPrefix(key, prefix+"output_bindings.") { continue } slotField := strings.TrimPrefix(key, prefix+"output_bindings.") dot := strings.LastIndex(slotField, ".") if dot <= 0 { continue } slot := strings.TrimSpace(slotField[:dot]) field := strings.TrimSpace(slotField[dot+1:]) if slot == "" || field == "" { continue } value := strings.TrimSpace(form.Get(key)) entry := bindings[slot] switch field { case "publish_hls_path": entry.PublishHLSPath = value case "publish_rtsp_port": entry.PublishRTSPPort = value case "publish_rtsp_path": entry.PublishRTSPPath = value case "channel_no": entry.ChannelNo = value default: continue } if strings.TrimSpace(entry.PublishHLSPath) == "" && strings.TrimSpace(entry.PublishRTSPPort) == "" && strings.TrimSpace(entry.PublishRTSPPath) == "" && strings.TrimSpace(entry.ChannelNo) == "" { continue } bindings[slot] = entry } if len(bindings) == 0 { return nil } return bindings } func inputBindingValue(bindings map[string]service.InputBindingEditor, slot string) string { if len(bindings) == 0 { return "" } return strings.TrimSpace(bindings[slot].VideoSourceRef) } func outputBindingFormValue(bindings map[string]service.OutputBindingEditor, slot string, field string) string { if len(bindings) == 0 { return "" } item, ok := bindings[slot] if !ok { return "" } switch field { case "publish_hls_path": return strings.TrimSpace(item.PublishHLSPath) case "publish_rtsp_port": return strings.TrimSpace(item.PublishRTSPPort) case "publish_rtsp_path": return strings.TrimSpace(item.PublishRTSPPath) case "channel_no": return strings.TrimSpace(item.ChannelNo) default: return "" } } func firstString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func selectedIDsFromQuery(values []string) []string { values = cleanFormList(values) if len(values) == 0 { return nil } seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func filterSelectedDeviceIDs(devices []*models.Device, candidates []string) []string { if len(candidates) == 0 || len(devices) == 0 { return nil } known := make(map[string]struct{}, len(devices)) for _, dev := range devices { if dev == nil { continue } id := strings.TrimSpace(dev.DeviceID) if id != "" { known[id] = struct{}{} } } seen := make(map[string]struct{}, len(candidates)) out := make([]string, 0, len(candidates)) for _, id := range candidates { id = strings.TrimSpace(id) if id == "" { continue } if _, ok := known[id]; !ok { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } if len(out) == 0 { return nil } return out } func selectedQueryString(ids []string) string { if len(ids) == 0 { return "" } values := url.Values{} for _, id := range ids { values.Add("selected", id) } return values.Encode() } func selectedURL(path string, ids []string) string { query := selectedQueryString(ids) if query == "" { return path } return path + "?" + query } func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMsg string) PageData { u.ensureDevicesLoaded() devices := u.registry.GetDevices() rows := make([]DeviceOverviewRow, 0, len(devices)) for _, dev := range devices { row := DeviceOverviewRow{Device: dev} status, _, err := u.loadConfigStatus(dev) row.ConfigStatus = status if err != nil { row.ConfigStatusErr = err.Error() } rows = append(rows, row) } online := 0 attention := 0 for _, d := range devices { if d.Online { online++ } else { attention++ } } failedTasks := 0 if u.tasks != nil { for _, t := range u.tasks.ListTasks() { if t.Status == models.TaskFailed { failedTasks++ } } } if selectedIDs == nil { selectedIDs = selectedIDsFromQuery(r.URL.Query()["selected"]) } selectedIDs = filterSelectedDeviceIDs(devices, selectedIDs) data := PageData{ Title: "设备", Devices: devices, DeviceRows: rows, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, RunningTaskCount: 0, FailedTaskCount: failedTasks, FoundCount: attention, SelectedDeviceIDs: selectedIDs, SelectedQuery: selectedQueryString(selectedIDs), SelectedDevicesURL: selectedURL("/ui/devices", selectedIDs), BatchConfigURL: selectedURL("/ui/devices/batch-config", selectedIDs), ReloadSummary: batchActionSummary(rows, selectedIDs, "reload"), RollbackSummary: batchActionSummary(rows, selectedIDs, "rollback"), } if errMsg != "" { data.Error = errMsg } return data } func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData { data := u.deviceOverviewPageData(r, selectedIDs, "") sources, err := u.preview.ListSources() data.Title = "下发设备分配" data.ConfigSources = sources data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs) assignments, assignErr := u.preview.ListDeviceAssignments() filteredAssignments := make([]service.DeviceAssignmentAsset, 0, len(assignments)) selectedSet := make(map[string]struct{}, len(data.SelectedDeviceIDs)) for _, id := range data.SelectedDeviceIDs { selectedSet[id] = struct{}{} } for _, item := range assignments { if _, ok := selectedSet[item.DeviceID]; ok { filteredAssignments = append(filteredAssignments, item) } } data.DeviceAssignments = filteredAssignments if err != nil { data.Error = err.Error() } else if assignErr != nil { data.Error = assignErr.Error() } return data } func selectedDevicesFromIDs(devices []*models.Device, ids []string) []*models.Device { if len(devices) == 0 || len(ids) == 0 { return nil } byID := make(map[string]*models.Device, len(devices)) for _, dev := range devices { if dev == nil { continue } byID[strings.TrimSpace(dev.DeviceID)] = dev } selected := make([]*models.Device, 0, len(ids)) for _, id := range ids { if dev := byID[strings.TrimSpace(id)]; dev != nil { selected = append(selected, dev) } } return selected } func previewResultFromJSON(raw string) *service.ConfigPreviewResult { raw = strings.TrimSpace(raw) if raw == "" { return nil } var doc map[string]any if err := json.Unmarshal([]byte(raw), &doc); err != nil { return nil } metadata, _ := doc["metadata"].(map[string]any) return &service.ConfigPreviewResult{ JSON: raw, Metadata: metadata, Size: len([]byte(raw)), } } func populateSelectionsFromPreview(data *PageData) { if data == nil || data.ConfigPreview == nil { return } if metadata := data.ConfigPreview.Metadata; metadata != nil { if v, _ := metadata["template"].(string); strings.TrimSpace(v) != "" { data.SelectedTemplate = v } if v, _ := metadata["profile"].(string); strings.TrimSpace(v) != "" { data.SelectedProfile = v } if v, _ := metadata["config_id"].(string); strings.TrimSpace(v) != "" { data.SelectedConfigID = v } if v, _ := metadata["config_version"].(string); strings.TrimSpace(v) != "" { data.SelectedVersion = v } if items, ok := metadata["overlays"].([]any); ok { overlays := make([]string, 0, len(items)) for _, item := range items { if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { overlays = append(overlays, s) } } if len(overlays) > 0 { data.SelectedOverlays = overlays } } } } func profileAssetTemplate(asset *service.ConfigProfileAsset) string { if asset == nil { return "" } for _, item := range asset.Instances { if v := strings.TrimSpace(item.Template); v != "" { return v } } return "" } func profileAssetBusinessName(asset *service.ConfigProfileAsset) string { if asset == nil { return "" } if v := strings.TrimSpace(asset.BusinessName); v != "" { return v } return strings.TrimSpace(asset.Name) } func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action string) string { if len(selectedIDs) == 0 { return "" } rowByID := make(map[string]DeviceOverviewRow, len(rows)) for _, row := range rows { if row.Device == nil { continue } rowByID[strings.TrimSpace(row.Device.DeviceID)] = row } lines := make([]string, 0, len(selectedIDs)) for _, id := range selectedIDs { row, ok := rowByID[strings.TrimSpace(id)] if !ok || row.Device == nil { continue } label := row.Device.DisplayName() switch action { case "reload": summary := "未取到当前运行配置" if row.ConfigStatus != nil { meta := row.ConfigStatus.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { summary = name if profile := strings.TrimSpace(meta.Profile); profile != "" { summary += " (" + profile + ")" } } else if profile := strings.TrimSpace(meta.Profile); profile != "" { summary = profile } else if configID := strings.TrimSpace(meta.ConfigID); configID != "" { summary = configID } } lines = append(lines, label+" -> "+summary) case "rollback": summary := "未取到可回滚运行配置" if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil { meta := row.ConfigStatus.PreviousConfig.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { summary = name if profile := strings.TrimSpace(meta.Profile); profile != "" { summary += " (" + profile + ")" } } else if profile := strings.TrimSpace(meta.Profile); profile != "" { summary = profile } else if configID := strings.TrimSpace(meta.ConfigID); configID != "" { summary = configID } } lines = append(lines, label+" -> "+summary) } } return strings.Join(lines, ";") } func (u *UI) actionDeviceConfigUIPlan(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } _ = r.ParseForm() raw := strings.TrimSpace(r.FormValue("json")) if raw == "" { raw = `{"instances":[]}` } if err := json.Unmarshal([]byte(raw), new(any)); err != nil { data := u.loadConfigUIData(dev) data.Error = "json 无效: " + err.Error() data.RawJSON = raw u.render(w, r, "config_ui", data) return } body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/plan", []byte(raw)) data := u.loadConfigUIData(dev) data.Message = fmt.Sprintf("POST /v1/config/ui/plan -> %d", code) data.RawText = prettyJSON(body) data.RawJSON = raw if err != nil { data.Error = err.Error() } u.render(w, r, "config_ui", data) } func (u *UI) actionDeviceConfigUIApply(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } _ = r.ParseForm() raw := strings.TrimSpace(r.FormValue("json")) if raw == "" { raw = `{"instances":[]}` } if err := json.Unmarshal([]byte(raw), new(any)); err != nil { data := u.loadConfigUIData(dev) data.Error = "json 无效: " + err.Error() data.RawJSON = raw u.render(w, r, "config_ui", data) return } body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/apply", []byte(raw)) data := u.loadConfigUIData(dev) data.Message = fmt.Sprintf("POST /v1/config/ui/apply -> %d", code) data.RawText = prettyJSON(body) data.RawJSON = raw if err != nil { data.Error = err.Error() } u.render(w, r, "config_ui", data) } func (u *UI) actionDeviceFaceGalleryUpload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } if err := r.ParseMultipartForm(500 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } file, hdr, err := r.FormFile("file") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer file.Close() resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, "/v1/face-gallery", file, "application/octet-stream", hdr.Size) data := u.loadConfigUIData(dev) data.Message = fmt.Sprintf("PUT /v1/face-gallery -> %d", code) data.RawText = prettyJSON(resp) if derr != nil { data.Error = derr.Error() } u.render(w, r, "config_ui", data) } func (u *UI) actionDeviceFaceGalleryReload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) if !ok { http.NotFound(w, r) return } resp, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/face-gallery/reload", nil) data := u.loadConfigUIData(dev) data.Message = fmt.Sprintf("POST /v1/face-gallery/reload -> %d", code) data.RawText = prettyJSON(resp) if err != nil { data.Error = err.Error() } u.render(w, r, "config_ui", data) } func (u *UI) pageMonitor(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() u.render(w, r, "monitor", PageData{Title: "视频监控", Devices: u.registry.GetDevices()}) } func (u *UI) apiMonitorChannels(w http.ResponseWriter, r *http.Request) { deviceID := r.URL.Query().Get("device_id") if deviceID == "" { http.Error(w, "missing device_id", http.StatusBadRequest) return } dev, ok := u.findDevice(deviceID) if !ok { http.Error(w, "device not found", http.StatusNotFound) return } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/preview/channels", nil) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) w.Write(body) } func (u *UI) proxyHLS(w http.ResponseWriter, r *http.Request) { // URL format: /hls/{deviceID}/{hls_path} path := strings.TrimPrefix(r.URL.Path, "/hls/") idx := strings.Index(path, "/") if idx < 0 { http.Error(w, "invalid path", http.StatusBadRequest) return } deviceID := path[:idx] hlsPath := path[idx+1:] dev, ok := u.findDevice(deviceID) if !ok { http.Error(w, "device not found", http.StatusNotFound) return } body, code, err := u.agent.Do("GET", dev.IP, dev.MediaPort, "/"+hlsPath, nil) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } // Set HLS-friendly headers if strings.HasSuffix(hlsPath, ".m3u8") { w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") w.Header().Set("Access-Control-Allow-Origin", "*") } else if strings.HasSuffix(hlsPath, ".ts") { w.Header().Set("Content-Type", "video/mp2t") w.Header().Set("Access-Control-Allow-Origin", "*") } w.Header().Set("Cache-Control", "no-cache") w.WriteHeader(code) w.Write(body) } func (u *UI) actionFaceGalleryAdd(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } name := strings.TrimSpace(r.FormValue("name")) if name == "" { http.Error(w, "name required", http.StatusBadRequest) return } // Save photo to dataset directory datasetDir := filepath.Join("dataset") personDir := filepath.Join(datasetDir, name) if err := os.MkdirAll(personDir, 0o755); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } file, hdr, err := r.FormFile("photo") if err != nil { http.Error(w, "photo required", http.StatusBadRequest) return } defer file.Close() dst := filepath.Join(personDir, hdr.Filename) f, err := os.Create(dst) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer f.Close() io.Copy(f, file) http.Redirect(w, r, "/ui/face-gallery?msg=ok", http.StatusFound) } func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) { addFaceDir := filepath.Join("..", "AddFaceTo3588") pythonExe := filepath.Join(addFaceDir, ".venv", "Scripts", "python.exe") // Fallback to system python if venv not available if _, err := os.Stat(pythonExe); os.IsNotExist(err) { pythonExe = "python" } builder := &service.FaceGalleryBuilder{ PythonExe: pythonExe, BuildScript: filepath.Join("tools", "build_gallery.py"), DetModel: filepath.Join("tools", "models", "RetinaFace_mobile320.onnx"), RecogModel: filepath.Join("tools", "models", "mobilefacenet_arcface_prenorm.onnx"), DetOutputsConfig: filepath.Join("tools", "models", "retinaface_mobile320_config.json"), DatasetDir: filepath.Join("dataset"), OutputDB: filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"), } output, err := builder.Build() if err != nil { msg := fmt.Sprintf("生成失败: %v", err) http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound) return } // Parse key stats from output for a clean message persons := "0" photos := "0" for _, line := range strings.Split(output, "\n") { if strings.HasPrefix(line, "enrolled_persons:") { persons = strings.TrimSpace(strings.TrimPrefix(line, "enrolled_persons:")) } if strings.HasPrefix(line, "ok_images:") { photos = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:")) } } msg := fmt.Sprintf("生成成功,共 %s 人,%s 张照片", persons, photos) http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound) } func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) { data := PageData{Title: "人脸库管理"} data.Message = r.URL.Query().Get("msg") if strings.TrimSpace(u.dbPath) != "" { if repo := storage.NewFaceGalleryRepo(u.dbPath); repo != nil { if persons, err := repo.ListPersons(); err == nil { data.FaceGalleryPersons = persons } } } u.render(w, r, "face_gallery", data) } func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) if id <= 0 { http.Error(w, "invalid", http.StatusBadRequest) return } if strings.TrimSpace(u.dbPath) != "" { repo := storage.NewFaceGalleryRepo(u.dbPath) repo.DeletePerson(id) } http.Redirect(w, r, "/ui/face-gallery", http.StatusFound) } func (u *UI) actionFaceGalleryImport(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(100 << 20); err != nil { http.Error(w, "form too large", http.StatusBadRequest) return } datasetDir := filepath.Join("dataset") count := 0 for _, files := range r.MultipartForm.File { for _, hdr := range files { fp := fullFilename(hdr) personName := personNameFromPath(fp) fileName := filepath.Base(fp) if personName == "" || fileName == "" { continue } personDir := filepath.Join(datasetDir, personName) os.MkdirAll(personDir, 0o755) dst := filepath.Join(personDir, fileName) // Skip if file already exists if _, err := os.Stat(dst); err == nil { continue } file, err := hdr.Open() if err != nil { continue } f, err := os.Create(dst) if err != nil { file.Close(); continue } io.Copy(f, file) f.Close() file.Close() count++ // Register in DB if strings.TrimSpace(u.dbPath) != "" { repo := storage.NewFaceGalleryRepo(u.dbPath) pid, _ := repo.FindOrCreatePerson(personName) repo.AddPhoto(pid, personName+"/"+fileName) } } } http.Redirect(w, r, fmt.Sprintf("/ui/face-gallery?msg=已导入 %d 张照片", count), http.StatusFound) } func (u *UI) rebuildFaceGallery() { builder := &service.FaceGalleryBuilder{ PythonExe: "python", BuildScript: filepath.Join("tools", "build_gallery.py"), DetModel: filepath.Join("tools", "models", "RetinaFace_mobile320.onnx"), RecogModel: filepath.Join("tools", "models", "mobilefacenet_arcface_prenorm.onnx"), DetOutputsConfig: filepath.Join("tools", "models", "retinaface_mobile320_config.json"), DatasetDir: filepath.Join("dataset"), OutputDB: filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"), } builder.Build() } // fullFilename extracts the full relative path from a webkitdirectory file upload, // bypassing Go 1.20+ sanitization that strips directory components. func fullFilename(hdr *multipart.FileHeader) string { cd := hdr.Header.Get("Content-Disposition") _, params, err := mime.ParseMediaType(cd) if err == nil { if name := params["filename"]; name != "" { return name } } return hdr.Filename } // personNameFromPath extracts the person name from a webkitdirectory path like "李清/photo.jpg". func personNameFromPath(path string) string { path = strings.ReplaceAll(path, "\\", "/") parts := strings.Split(path, "/") if len(parts) < 2 { return "" } return strings.TrimSpace(parts[len(parts)-2]) } func (u *UI) serveFacePhoto(w http.ResponseWriter, r *http.Request) { path := chi.URLParam(r, "*") if path == "" || strings.Contains(path, "..") { http.Error(w, "invalid", http.StatusBadRequest) return } fullPath := filepath.Join("dataset", path) http.ServeFile(w, r, fullPath) } func (u *UI) actionFaceGalleryRename(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) name := strings.TrimSpace(r.FormValue("name")) if id <= 0 || name == "" { http.Error(w, "invalid", http.StatusBadRequest) return } if strings.TrimSpace(u.dbPath) != "" { storage.NewFaceGalleryRepo(u.dbPath).RenamePerson(id, name) } http.Redirect(w, r, "/ui/face-gallery", http.StatusFound) } func (u *UI) actionFaceGalleryAddPhoto(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "form error", http.StatusBadRequest); return } id, _ := strconv.Atoi(r.FormValue("id")) if id <= 0 { http.Error(w, "invalid id", http.StatusBadRequest); return } file, hdr, err := r.FormFile("photo") if err != nil { http.Error(w, "photo required", http.StatusBadRequest); return } defer file.Close() // Get person name for directory if strings.TrimSpace(u.dbPath) == "" { http.Redirect(w, r, "/ui/face-gallery", http.StatusFound); return } repo := storage.NewFaceGalleryRepo(u.dbPath) persons, _ := repo.ListPersons() var personName string for _, p := range persons { if p.ID == id { personName = p.Name; break } } if personName == "" { http.Error(w, "person not found", http.StatusNotFound); return } personDir := filepath.Join("dataset", personName) os.MkdirAll(personDir, 0o755) dst := filepath.Join(personDir, hdr.Filename) f, err := os.Create(dst) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return } defer f.Close() io.Copy(f, file) repo.AddPhoto(id, personName+"/"+hdr.Filename) http.Redirect(w, r, "/ui/face-gallery", http.StatusFound) } func (u *UI) actionFaceGalleryDeletePhoto(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) if id > 0 && strings.TrimSpace(u.dbPath) != "" { storage.NewFaceGalleryRepo(u.dbPath).DeletePhoto(id) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"ok": true}) } func (u *UI) apiDeviceMetrics(w http.ResponseWriter, r *http.Request) { deviceID := r.URL.Query().Get("device_id") if deviceID == "" { http.Error(w, "missing device_id", http.StatusBadRequest); return } dev, ok := u.findDevice(deviceID) if !ok { http.Error(w, "device not found", http.StatusNotFound); return } body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway); return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) w.Write(body) } type DeviceMetric struct { Name string `json:"name"` CPU float64 `json:"cpu"` Mem float64 `json:"mem"` NPU float64 `json:"npu"` Disk float64 `json:"disk"` Temperature float64 `json:"temperature"` } type ConsoleDeviceData struct { Device *models.Device CPUUsage float64 MemUsage float64 NPUUsage float64 DiskUsage float64 Temperature float64 MaxUnits int Channels []ConsoleChannel Features []ConsoleFeature Cameras []string Busy bool BusyMsg string } type ConsoleChannel struct { Name string Display string SourceName string URL string } type ConsoleFeature struct { Key string Label string Description string Enabled bool Available bool // device actually supports this (from agent /v1/capabilities) Conflict bool // enabled in DB but unavailable on device }