1925 lines
67 KiB
Plaintext
1925 lines
67 KiB
Plaintext
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
|
||
}
|