safesight-control/docs/source_code_p31_65.txt

1925 lines
67 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}