From b7268212e3ab30d8cd130294568605aae8721a8a Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Fri, 8 May 2026 20:05:40 +0800 Subject: [PATCH] chore: convert all tabs to spaces --- cmd/managerd/main.go | 264 +- internal/api/handlers.go | 596 +- internal/api/handlers_test.go | 480 +- internal/api/openapi.go | 558 +- internal/config/config.go | 96 +- internal/config/config_test.go | 24 +- internal/models/device.go | 94 +- internal/models/task.go | 62 +- internal/service/agent_client.go | 190 +- internal/service/alarm_collector.go | 324 +- internal/service/config_assets.go | 3094 +++---- internal/service/config_assets_test.go | 1830 ++-- internal/service/config_preview.go | 1056 +-- internal/service/config_preview_test.go | 868 +- internal/service/config_runtime_render.go | 760 +- internal/service/discovery.go | 280 +- internal/service/face_gallery_builder.go | 90 +- internal/service/model_management.go | 378 +- internal/service/model_management_test.go | 162 +- internal/service/profile_editor.go | 774 +- internal/service/registry.go | 278 +- internal/service/registry_test.go | 204 +- internal/service/resource_management.go | 468 +- internal/service/resource_management_test.go | 150 +- internal/service/standard_templates.go | 138 +- internal/service/task.go | 1172 +-- internal/service/task_test.go | 520 +- internal/service/template.go | 80 +- internal/service/template_slots.go | 102 +- internal/service/template_slots_test.go | 52 +- internal/service/template_test.go | 66 +- internal/storage/assets_repo.go | 966 +-- internal/storage/assets_repo_test.go | 310 +- internal/storage/audit_logs_repo.go | 88 +- internal/storage/audit_logs_repo_test.go | 44 +- internal/storage/device_config_state_repo.go | 122 +- .../storage/device_config_state_repo_test.go | 50 +- internal/storage/devices_repo.go | 102 +- internal/storage/devices_repo_test.go | 56 +- internal/storage/face_gallery_repo.go | 260 +- internal/storage/migrate.go | 458 +- internal/storage/models_repo.go | 108 +- internal/storage/models_repo_test.go | 54 +- internal/storage/paths.go | 16 +- internal/storage/resources_repo.go | 162 +- internal/storage/resources_repo_test.go | 132 +- internal/storage/sqlite.go | 62 +- internal/storage/sqlite_test.go | 132 +- internal/storage/tasks_repo.go | 208 +- internal/storage/tasks_repo_test.go | 64 +- internal/web/graph_node_types.go | 256 +- internal/web/ui.go | 7258 ++++++++-------- internal/web/ui_test.go | 7456 ++++++++--------- 53 files changed, 16787 insertions(+), 16787 deletions(-) diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index dfde7d7..b730f18 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -1,153 +1,153 @@ package main import ( - "fmt" - "log" - "net/http" - "os" - "path/filepath" + "fmt" + "log" + "net/http" + "os" + "path/filepath" - "3588AdminBackend/internal/api" - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/service" - "3588AdminBackend/internal/storage" - "3588AdminBackend/internal/web" + "3588AdminBackend/internal/api" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/service" + "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/web" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/cors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" ) func main() { - cfgPath := "managerd.json" - if len(os.Args) > 1 { - cfgPath = os.Args[1] - } + cfgPath := "managerd.json" + if len(os.Args) > 1 { + cfgPath = os.Args[1] + } - cfg, err := config.LoadConfig(cfgPath) - if err != nil { - log.Fatalf("failed to load config: %v", err) - } + cfg, err := config.LoadConfig(cfgPath) + if err != nil { + log.Fatalf("failed to load config: %v", err) + } - // Initialize Services - agentClient := service.NewAgentClient(cfg) - store, err := storage.OpenSQLite(cfg.DBPathOrDefault()) - if err != nil { - log.Fatalf("failed to open storage: %v", err) - } - regSvc := service.NewRegistryService(cfg, agentClient, storage.NewDevicesRepo(store.DB())) - discoSvc := service.NewDiscoveryService(cfg, regSvc) - regSvc.SetDiscovery(discoSvc) - defer store.Close() - taskRepo := storage.NewTasksRepo(store.DB()) - assetsRepo := storage.NewAssetsRepo(store.DB()) - modelsRepo := storage.NewModelsRepo(store.DB()) - if imported, err := service.ImportStandardTemplatesFromDir(assetsRepo, filepath.Join("templates", "standard_templates")); err != nil { - log.Fatalf("import standard templates: %v", err) - } else if imported > 0 { - log.Printf("imported %d standard templates", imported) - } - standardModelsDir := filepath.Join("models", "standard_models") - modelSvc := service.NewModelManagementService(modelsRepo) - if err := modelSvc.SyncStandardModelsFromDirectory(standardModelsDir); err != nil { - log.Fatalf("sync standard models: %v", err) - } - resourcesRepo := storage.NewResourcesRepo(store.DB()) - resourceSvc := service.NewResourceManagementService(resourcesRepo) - standardResourcesDir := filepath.Join("resources", "standard_resources") - if err := resourceSvc.SyncStandardResourcesFromDirectory(standardResourcesDir); err != nil { - log.Printf("sync standard resources: %v", err) - } - stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) - auditRepo := storage.NewAuditLogsRepo(store.DB()) - taskSvc := service.NewTaskService(cfg, agentClient, regSvc, taskRepo) - taskSvc.SetStandardModels(modelsRepo, standardModelsDir) - taskSvc.SetStandardResources(resourcesRepo) - taskSvc.SetDeviceConfigStateRepo(stateRepo) - taskSvc.SetAuditLogRepo(auditRepo) - if err := taskSvc.LoadPersistedTasks(); err != nil { - log.Printf("load persisted tasks: %v", err) - } - alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc) - alarmCollector.Start() - tplSvc := service.NewTemplateService(cfg) - h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc) + // Initialize Services + agentClient := service.NewAgentClient(cfg) + store, err := storage.OpenSQLite(cfg.DBPathOrDefault()) + if err != nil { + log.Fatalf("failed to open storage: %v", err) + } + regSvc := service.NewRegistryService(cfg, agentClient, storage.NewDevicesRepo(store.DB())) + discoSvc := service.NewDiscoveryService(cfg, regSvc) + regSvc.SetDiscovery(discoSvc) + defer store.Close() + taskRepo := storage.NewTasksRepo(store.DB()) + assetsRepo := storage.NewAssetsRepo(store.DB()) + modelsRepo := storage.NewModelsRepo(store.DB()) + if imported, err := service.ImportStandardTemplatesFromDir(assetsRepo, filepath.Join("templates", "standard_templates")); err != nil { + log.Fatalf("import standard templates: %v", err) + } else if imported > 0 { + log.Printf("imported %d standard templates", imported) + } + standardModelsDir := filepath.Join("models", "standard_models") + modelSvc := service.NewModelManagementService(modelsRepo) + if err := modelSvc.SyncStandardModelsFromDirectory(standardModelsDir); err != nil { + log.Fatalf("sync standard models: %v", err) + } + resourcesRepo := storage.NewResourcesRepo(store.DB()) + resourceSvc := service.NewResourceManagementService(resourcesRepo) + standardResourcesDir := filepath.Join("resources", "standard_resources") + if err := resourceSvc.SyncStandardResourcesFromDirectory(standardResourcesDir); err != nil { + log.Printf("sync standard resources: %v", err) + } + stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) + auditRepo := storage.NewAuditLogsRepo(store.DB()) + taskSvc := service.NewTaskService(cfg, agentClient, regSvc, taskRepo) + taskSvc.SetStandardModels(modelsRepo, standardModelsDir) + taskSvc.SetStandardResources(resourcesRepo) + taskSvc.SetDeviceConfigStateRepo(stateRepo) + taskSvc.SetAuditLogRepo(auditRepo) + if err := taskSvc.LoadPersistedTasks(); err != nil { + log.Printf("load persisted tasks: %v", err) + } + alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc) + alarmCollector.Start() + tplSvc := service.NewTemplateService(cfg) + h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc) - r := chi.NewRouter() - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-RK-Token", "X-Model-Sha256"}, - MaxAge: 300, - })) + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-RK-Token", "X-Model-Sha256"}, + MaxAge: 300, + })) - r.Get("/health", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - }) - r.Get("/openapi.json", api.OpenAPI) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui", http.StatusFound) - }) + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + r.Get("/openapi.json", api.OpenAPI) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui", http.StatusFound) + }) - ui, err := web.NewUI(discoSvc, regSvc, agentClient, taskSvc, tplSvc, service.NewConfigPreviewService(cfg, assetsRepo)) - if err != nil { - log.Fatalf("failed to init ui: %v", err) - } - ui.SetStateRepo(stateRepo) - ui.SetAuditRepo(auditRepo) - ui.SetDBPath(cfg.DBPathOrDefault()) - ui.SetResourcesRepo(resourcesRepo) - ui.SetAlarmCollector(alarmCollector) - uiRouter, err := ui.Routes() - if err != nil { - log.Fatalf("failed to init ui routes: %v", err) - } - r.Mount("/ui", http.StripPrefix("/ui", uiRouter)) + ui, err := web.NewUI(discoSvc, regSvc, agentClient, taskSvc, tplSvc, service.NewConfigPreviewService(cfg, assetsRepo)) + if err != nil { + log.Fatalf("failed to init ui: %v", err) + } + ui.SetStateRepo(stateRepo) + ui.SetAuditRepo(auditRepo) + ui.SetDBPath(cfg.DBPathOrDefault()) + ui.SetResourcesRepo(resourcesRepo) + ui.SetAlarmCollector(alarmCollector) + uiRouter, err := ui.Routes() + if err != nil { + log.Fatalf("failed to init ui routes: %v", err) + } + r.Mount("/ui", http.StripPrefix("/ui", uiRouter)) - // API Routes - r.Route("/api", func(r chi.Router) { - r.Post("/discovery/search", h.Search) - r.Get("/devices", h.ListDevices) - r.Post("/devices", h.CreateDevice) - r.Get("/devices/{id}", h.GetDevice) + // API Routes + r.Route("/api", func(r chi.Router) { + r.Post("/discovery/search", h.Search) + r.Get("/devices", h.ListDevices) + r.Post("/devices", h.CreateDevice) + r.Get("/devices/{id}", h.GetDevice) - // Proxy routes for device actions - r.Get("/devices/{id}/info", h.ProxyAgent) - r.Get("/devices/{id}/config/status", h.ProxyAgent) - r.Put("/devices/{id}/config/candidate", h.ProxyAgent) - r.Post("/devices/{id}/config/candidate/apply", h.ProxyAgent) - r.Post("/devices/{id}/reload", h.ProxyAgent) - r.Post("/devices/{id}/rollback", h.ProxyAgent) - r.Get("/devices/{id}/graphs", h.ProxyAgent) - r.Get("/devices/{id}/graphs/{name}", h.ProxyAgent) - r.Get("/devices/{id}/logs", h.ProxyAgent) - r.Post("/devices/{id}/config/apply", h.ProxyAgent) - r.Get("/devices/{id}/models", h.ProxyAgent) - r.Post("/devices/{id}/media-server/start", h.ProxyAgent) - r.Post("/devices/{id}/media-server/restart", h.ProxyAgent) - r.Post("/devices/{id}/media-server/stop", h.ProxyAgent) - r.Get("/devices/{id}/media-server/status", h.ProxyAgent) + // Proxy routes for device actions + r.Get("/devices/{id}/info", h.ProxyAgent) + r.Get("/devices/{id}/config/status", h.ProxyAgent) + r.Put("/devices/{id}/config/candidate", h.ProxyAgent) + r.Post("/devices/{id}/config/candidate/apply", h.ProxyAgent) + r.Post("/devices/{id}/reload", h.ProxyAgent) + r.Post("/devices/{id}/rollback", h.ProxyAgent) + r.Get("/devices/{id}/graphs", h.ProxyAgent) + r.Get("/devices/{id}/graphs/{name}", h.ProxyAgent) + r.Get("/devices/{id}/logs", h.ProxyAgent) + r.Post("/devices/{id}/config/apply", h.ProxyAgent) + r.Get("/devices/{id}/models", h.ProxyAgent) + r.Post("/devices/{id}/media-server/start", h.ProxyAgent) + r.Post("/devices/{id}/media-server/restart", h.ProxyAgent) + r.Post("/devices/{id}/media-server/stop", h.ProxyAgent) + r.Get("/devices/{id}/media-server/status", h.ProxyAgent) - // Generic passthrough for device agent /v1/* (covers config/ui/*, face-gallery, etc.) - r.Handle("/devices/{id}/v1/*", http.HandlerFunc(h.ProxyAgentV1)) + // Generic passthrough for device agent /v1/* (covers config/ui/*, face-gallery, etc.) + r.Handle("/devices/{id}/v1/*", http.HandlerFunc(h.ProxyAgentV1)) - // Task routes - r.Post("/tasks", h.CreateTask) - r.Get("/tasks", h.ListTasks) - r.Get("/tasks/{id}/events", h.TaskEvents) + // Task routes + r.Post("/tasks", h.CreateTask) + r.Get("/tasks", h.ListTasks) + r.Get("/tasks/{id}/events", h.TaskEvents) - // Template routes - r.Get("/templates", h.ListTemplates) - r.Get("/templates/{name}", h.GetTemplate) + // Template routes + r.Get("/templates", h.ListTemplates) + r.Get("/templates/{name}", h.GetTemplate) - // Model routes - r.Post("/devices/{id}/models/upload", h.UploadModel) - }) + // Model routes + r.Post("/devices/{id}/models/upload", h.UploadModel) + }) - fmt.Printf("Starting managerd on %s\n", cfg.Listen) - if err := http.ListenAndServe(cfg.Listen, r); err != nil { - log.Fatalf("failed to start server: %v", err) - } + fmt.Printf("Starting managerd on %s\n", cfg.Listen) + if err := http.ListenAndServe(cfg.Listen, r); err != nil { + log.Fatalf("failed to start server: %v", err) + } } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 1691e41..8321b88 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1,373 +1,373 @@ package api import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "time" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/service" - "github.com/go-chi/chi/v5" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/service" + "github.com/go-chi/chi/v5" ) type Handler struct { - discovery *service.DiscoveryService - registry *service.RegistryService - agent *service.AgentClient - tasks *service.TaskService - templates *service.TemplateService + discovery *service.DiscoveryService + registry *service.RegistryService + agent *service.AgentClient + tasks *service.TaskService + templates *service.TemplateService } func NewHandler(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService) *Handler { - return &Handler{ - discovery: discovery, - registry: registry, - agent: agent, - tasks: tasks, - templates: templates, - } + return &Handler{ + discovery: discovery, + registry: registry, + agent: agent, + tasks: tasks, + templates: templates, + } } func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { - var req struct { - TimeoutMs int `json:"timeout_ms"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - req.TimeoutMs = 1200 // Default - } + var req struct { + TimeoutMs int `json:"timeout_ms"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + req.TimeoutMs = 1200 // Default + } - items, err := h.discovery.Search(req.TimeoutMs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + items, err := h.discovery.Search(req.TimeoutMs) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - json.NewEncoder(w).Encode(map[string]interface{}{ - "items": items, - }) + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": items, + }) } func (h *Handler) ListDevices(w http.ResponseWriter, r *http.Request) { - h.ensureDevicesLoaded() - items := h.registry.GetDevices() - json.NewEncoder(w).Encode(map[string]interface{}{ - "items": items, - }) + h.ensureDevicesLoaded() + items := h.registry.GetDevices() + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": items, + }) } func (h *Handler) CreateDevice(w http.ResponseWriter, r *http.Request) { - var req struct { - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` - IP string `json:"ip"` - AgentPort int `json:"agent_port"` - MediaPort int `json:"media_port"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } - if req.DeviceID == "" || req.IP == "" { - http.Error(w, "device_id and ip are required", http.StatusBadRequest) - return - } - if req.AgentPort == 0 { - req.AgentPort = 9100 - } - if req.MediaPort == 0 { - req.MediaPort = 9000 - } + var req struct { + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + IP string `json:"ip"` + AgentPort int `json:"agent_port"` + MediaPort int `json:"media_port"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if req.DeviceID == "" || req.IP == "" { + http.Error(w, "device_id and ip are required", http.StatusBadRequest) + return + } + if req.AgentPort == 0 { + req.AgentPort = 9100 + } + if req.MediaPort == 0 { + req.MediaPort = 9000 + } - dev := &models.Device{ - DeviceID: req.DeviceID, - DeviceName: req.DeviceName, - IP: req.IP, - AgentPort: req.AgentPort, - MediaPort: req.MediaPort, - Online: true, - LastSeenMs: time.Now().UnixMilli(), - } - h.registry.UpdateDevice(dev) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + dev := &models.Device{ + DeviceID: req.DeviceID, + DeviceName: req.DeviceName, + IP: req.IP, + AgentPort: req.AgentPort, + MediaPort: req.MediaPort, + Online: true, + LastSeenMs: time.Now().UnixMilli(), + } + h.registry.UpdateDevice(dev) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) } func (h *Handler) GetDevice(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - dev, ok := h.findDevice(id) - if !ok { - http.Error(w, "device not found", http.StatusNotFound) - return - } - json.NewEncoder(w).Encode(dev) + id := chi.URLParam(r, "id") + dev, ok := h.findDevice(id) + if !ok { + http.Error(w, "device not found", http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(dev) } func (h *Handler) findDevice(id string) (*models.Device, bool) { - h.ensureDevicesLoaded() - devices := h.registry.GetDevices() - for _, d := range devices { - if d.DeviceID == id { - return d, true - } - } - if h.discovery != nil { - _, _ = h.discovery.SearchDefault() - devices = h.registry.GetDevices() - for _, d := range devices { - if d.DeviceID == id { - return d, true - } - } - } - return nil, false + h.ensureDevicesLoaded() + devices := h.registry.GetDevices() + for _, d := range devices { + if d.DeviceID == id { + return d, true + } + } + if h.discovery != nil { + _, _ = h.discovery.SearchDefault() + devices = h.registry.GetDevices() + for _, d := range devices { + if d.DeviceID == id { + return d, true + } + } + } + return nil, false } func (h *Handler) ensureDevicesLoaded() { - if h.registry == nil || h.discovery == nil { - return - } - devices := h.registry.GetDevices() - if len(devices) > 0 { - hasOnline := false - for _, d := range devices { - if d.Online { - hasOnline = true - break - } - } - if hasOnline { - return - } - } - _, _ = h.discovery.SearchDefault() - if len(h.registry.GetDevices()) == 0 { - _, _ = h.discovery.SearchDefault() - } + if h.registry == nil || h.discovery == nil { + return + } + devices := h.registry.GetDevices() + if len(devices) > 0 { + hasOnline := false + for _, d := range devices { + if d.Online { + hasOnline = true + break + } + } + if hasOnline { + return + } + } + _, _ = h.discovery.SearchDefault() + if len(h.registry.GetDevices()) == 0 { + _, _ = h.discovery.SearchDefault() + } } func (h *Handler) ProxyAgent(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - name := chi.URLParam(r, "name") - dev, ok := h.findDevice(id) - if !ok { - http.Error(w, "device not found", http.StatusNotFound) - return - } - var agentPath string - method := r.Method + id := chi.URLParam(r, "id") + name := chi.URLParam(r, "name") + dev, ok := h.findDevice(id) + if !ok { + http.Error(w, "device not found", http.StatusNotFound) + return + } + var agentPath string + method := r.Method - switch { - case r.URL.Path == fmt.Sprintf("/api/devices/%s/info", id): - agentPath = "/v1/info" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/status", id): - agentPath = "/v1/config/status" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/candidate", id): - agentPath = "/v1/config/candidate" - method = "PUT" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/candidate/apply", id): - agentPath = "/v1/config/candidate/apply" - method = "POST" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/reload", id): - agentPath = "/v1/media-server/reload" - method = "POST" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/rollback", id): - agentPath = "/v1/media-server/rollback" - method = "POST" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/start", id): - agentPath = "/v1/media-server/start" - method = "POST" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/restart", id): - agentPath = "/v1/media-server/restart" - method = "POST" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/stop", id): - agentPath = "/v1/media-server/stop" - method = "POST" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/status", id): - agentPath = "/v1/media-server/status" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/graphs", id): - agentPath = "/v1/graphs" - case name != "" && r.URL.Path == fmt.Sprintf("/api/devices/%s/graphs/%s", id, name): - agentPath = "/v1/graphs/" + url.PathEscape(name) - case r.URL.Path == fmt.Sprintf("/api/devices/%s/logs", id): - agentPath = "/v1/logs/recent" - if q := r.URL.RawQuery; q != "" { - agentPath += "?" + q - } - case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/apply", id): - agentPath = "/v1/config" - method = "PUT" - case r.URL.Path == fmt.Sprintf("/api/devices/%s/models", id): - agentPath = "/v1/models" - default: - http.Error(w, "path not mapped", http.StatusNotFound) - return - } + switch { + case r.URL.Path == fmt.Sprintf("/api/devices/%s/info", id): + agentPath = "/v1/info" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/status", id): + agentPath = "/v1/config/status" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/candidate", id): + agentPath = "/v1/config/candidate" + method = "PUT" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/candidate/apply", id): + agentPath = "/v1/config/candidate/apply" + method = "POST" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/reload", id): + agentPath = "/v1/media-server/reload" + method = "POST" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/rollback", id): + agentPath = "/v1/media-server/rollback" + method = "POST" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/start", id): + agentPath = "/v1/media-server/start" + method = "POST" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/restart", id): + agentPath = "/v1/media-server/restart" + method = "POST" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/stop", id): + agentPath = "/v1/media-server/stop" + method = "POST" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/status", id): + agentPath = "/v1/media-server/status" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/graphs", id): + agentPath = "/v1/graphs" + case name != "" && r.URL.Path == fmt.Sprintf("/api/devices/%s/graphs/%s", id, name): + agentPath = "/v1/graphs/" + url.PathEscape(name) + case r.URL.Path == fmt.Sprintf("/api/devices/%s/logs", id): + agentPath = "/v1/logs/recent" + if q := r.URL.RawQuery; q != "" { + agentPath += "?" + q + } + case r.URL.Path == fmt.Sprintf("/api/devices/%s/config/apply", id): + agentPath = "/v1/config" + method = "PUT" + case r.URL.Path == fmt.Sprintf("/api/devices/%s/models", id): + agentPath = "/v1/models" + default: + http.Error(w, "path not mapped", http.StatusNotFound) + return + } - contentType := r.Header.Get("Content-Type") - resp, code, err := h.agent.DoStream(method, dev.IP, dev.AgentPort, agentPath, r.Body, contentType, r.ContentLength) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + contentType := r.Header.Get("Content-Type") + resp, code, err := h.agent.DoStream(method, dev.IP, dev.AgentPort, agentPath, r.Body, contentType, r.ContentLength) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - // Update online status on successful response - if code < 500 && h.registry != nil { - h.registry.TouchDevice(id) - } + // Update online status on successful response + if code < 500 && h.registry != nil { + h.registry.TouchDevice(id) + } - w.WriteHeader(code) - w.Write(resp) + w.WriteHeader(code) + w.Write(resp) } // ProxyAgentV1 proxies any request under /api/devices/{id}/v1/* to the device agent as-is (path preserved). func (h *Handler) ProxyAgentV1(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - dev, ok := h.findDevice(id) - if !ok { - http.Error(w, "device not found", http.StatusNotFound) - return - } - prefix := fmt.Sprintf("/api/devices/%s", id) - agentPath := strings.TrimPrefix(r.URL.Path, prefix) - if !strings.HasPrefix(agentPath, "/v1/") && agentPath != "/v1" { - http.Error(w, "path not mapped", http.StatusNotFound) - return - } - if q := r.URL.RawQuery; q != "" { - agentPath += "?" + q - } + id := chi.URLParam(r, "id") + dev, ok := h.findDevice(id) + if !ok { + http.Error(w, "device not found", http.StatusNotFound) + return + } + prefix := fmt.Sprintf("/api/devices/%s", id) + agentPath := strings.TrimPrefix(r.URL.Path, prefix) + if !strings.HasPrefix(agentPath, "/v1/") && agentPath != "/v1" { + http.Error(w, "path not mapped", http.StatusNotFound) + return + } + if q := r.URL.RawQuery; q != "" { + agentPath += "?" + q + } - contentType := r.Header.Get("Content-Type") - resp, code, err := h.agent.DoStream(r.Method, dev.IP, dev.AgentPort, agentPath, r.Body, contentType, r.ContentLength) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + contentType := r.Header.Get("Content-Type") + resp, code, err := h.agent.DoStream(r.Method, dev.IP, dev.AgentPort, agentPath, r.Body, contentType, r.ContentLength) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - // Update online status on successful response - if code < 500 && h.registry != nil { - h.registry.TouchDevice(id) - } + // Update online status on successful response + if code < 500 && h.registry != nil { + h.registry.TouchDevice(id) + } - w.WriteHeader(code) - _, _ = w.Write(resp) + w.WriteHeader(code) + _, _ = w.Write(resp) } func (h *Handler) CreateTask(w http.ResponseWriter, r *http.Request) { - var req struct { - Type string `json:"type"` - DeviceIDs []string `json:"device_ids"` - Payload interface{} `json:"payload"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } + var req struct { + Type string `json:"type"` + DeviceIDs []string `json:"device_ids"` + Payload interface{} `json:"payload"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } - task, err := h.tasks.CreateTask(req.Type, req.DeviceIDs, req.Payload) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + task, err := h.tasks.CreateTask(req.Type, req.DeviceIDs, req.Payload) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - json.NewEncoder(w).Encode(map[string]string{"task_id": task.ID}) + json.NewEncoder(w).Encode(map[string]string{"task_id": task.ID}) } func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) { - if h.tasks == nil { - http.Error(w, "task service not initialized", http.StatusInternalServerError) - return - } + if h.tasks == nil { + http.Error(w, "task service not initialized", http.StatusInternalServerError) + return + } - items := h.tasks.ListTasks() - json.NewEncoder(w).Encode(map[string]interface{}{ - "items": items, - }) + items := h.tasks.ListTasks() + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": items, + }) } func (h *Handler) TaskEvents(w http.ResponseWriter, r *http.Request) { - taskID := chi.URLParam(r, "id") - ch, cleanup := h.tasks.Subscribe(taskID) - defer cleanup() + taskID := chi.URLParam(r, "id") + ch, cleanup := h.tasks.Subscribe(taskID) + defer cleanup() - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "SSE not supported", http.StatusInternalServerError) - return - } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "SSE not supported", http.StatusInternalServerError) + return + } - for { - select { - case <-r.Context().Done(): - return - case update := <-ch: - data, _ := json.Marshal(update) - fmt.Fprintf(w, "event: device_update\ndata: %s\n\n", data) - flusher.Flush() - } - } + for { + select { + case <-r.Context().Done(): + return + case update := <-ch: + data, _ := json.Marshal(update) + fmt.Fprintf(w, "event: device_update\ndata: %s\n\n", data) + flusher.Flush() + } + } } func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) { - list, err := h.templates.ListTemplates() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - json.NewEncoder(w).Encode(list) + list, err := h.templates.ListTemplates() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(list) } func (h *Handler) GetTemplate(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - t, err := h.templates.GetTemplate(name) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - json.NewEncoder(w).Encode(t) + name := chi.URLParam(r, "name") + t, err := h.templates.GetTemplate(name) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(t) } func (h *Handler) UploadModel(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - dev, ok := h.findDevice(id) - if !ok { - http.Error(w, "device not found", http.StatusNotFound) - return - } + id := chi.URLParam(r, "id") + dev, ok := h.findDevice(id) + if !ok { + http.Error(w, "device not found", http.StatusNotFound) + return + } - // Parse multipart - if err := r.ParseMultipartForm(100 << 20); err != nil { // 100MB - http.Error(w, err.Error(), http.StatusBadRequest) - return - } + // Parse multipart + if err := r.ParseMultipartForm(100 << 20); err != nil { // 100MB + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - name := r.FormValue("name") - file, hdr, err := r.FormFile("file") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - defer file.Close() + name := r.FormValue("name") + file, hdr, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() - if name == "" { - http.Error(w, "name is required", http.StatusBadRequest) - return - } + if name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } - // Forward to agent - agentPath := fmt.Sprintf("/v1/models/%s", name) - resp, code, err := h.agent.DoStream("PUT", dev.IP, dev.AgentPort, agentPath, file, "application/octet-stream", hdr.Size) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + // Forward to agent + agentPath := fmt.Sprintf("/v1/models/%s", name) + resp, code, err := h.agent.DoStream("PUT", dev.IP, dev.AgentPort, agentPath, file, "application/octet-stream", hdr.Size) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - w.WriteHeader(code) - w.Write(resp) + w.WriteHeader(code) + w.Write(resp) } diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 115d152..b4b3c4a 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -1,309 +1,309 @@ package api import ( - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/service" - "bytes" - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - "time" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/service" + "bytes" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" - "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5" ) func TestHandler_ListDevices(t *testing.T) { - cfg := &config.Config{} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: "127.0.0.1"}) + cfg := &config.Config{} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: "127.0.0.1"}) - h := NewHandler(nil, reg, agent, nil, nil) + h := NewHandler(nil, reg, agent, nil, nil) - req, _ := http.NewRequest("GET", "/api/devices", nil) - rr := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/devices", nil) + rr := httptest.NewRecorder() - h.ListDevices(rr, req) + h.ListDevices(rr, req) - if rr.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", rr.Code) - } + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } - var resp map[string][]models.Device - json.Unmarshal(rr.Body.Bytes(), &resp) + var resp map[string][]models.Device + json.Unmarshal(rr.Body.Bytes(), &resp) - if len(resp["items"]) != 1 { - t.Errorf("expected 1 item, got %d", len(resp["items"])) - } + if len(resp["items"]) != 1 { + t.Errorf("expected 1 item, got %d", len(resp["items"])) + } } func TestHandler_CreateTask(t *testing.T) { - cfg := &config.Config{Concurrency: 1} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - tasks := service.NewTaskService(cfg, agent, reg) + cfg := &config.Config{Concurrency: 1} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + tasks := service.NewTaskService(cfg, agent, reg) - h := NewHandler(nil, reg, agent, tasks, nil) + h := NewHandler(nil, reg, agent, tasks, nil) - body := map[string]interface{}{ - "type": "config_apply", - "device_ids": []string{"dev1"}, - "payload": map[string]string{"foo": "bar"}, - } - b, _ := json.Marshal(body) + body := map[string]interface{}{ + "type": "config_apply", + "device_ids": []string{"dev1"}, + "payload": map[string]string{"foo": "bar"}, + } + b, _ := json.Marshal(body) - req, _ := http.NewRequest("POST", "/api/tasks", bytes.NewBuffer(b)) - rr := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/tasks", bytes.NewBuffer(b)) + rr := httptest.NewRecorder() - h.CreateTask(rr, req) + h.CreateTask(rr, req) - if rr.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", rr.Code) - } + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } - var resp map[string]string - json.Unmarshal(rr.Body.Bytes(), &resp) + var resp map[string]string + json.Unmarshal(rr.Body.Bytes(), &resp) - if resp["task_id"] == "" { - t.Error("expected task_id in response") - } + if resp["task_id"] == "" { + t.Error("expected task_id in response") + } } func TestHandler_ListTasks(t *testing.T) { - cfg := &config.Config{Concurrency: 1} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - tasks := service.NewTaskService(cfg, agent, reg) + cfg := &config.Config{Concurrency: 1} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + tasks := service.NewTaskService(cfg, agent, reg) - // Create a task so list is non-empty - if _, err := tasks.CreateTask("config_apply", []string{"dev1"}, map[string]string{"foo": "bar"}); err != nil { - t.Fatalf("failed to create task: %v", err) - } + // Create a task so list is non-empty + if _, err := tasks.CreateTask("config_apply", []string{"dev1"}, map[string]string{"foo": "bar"}); err != nil { + t.Fatalf("failed to create task: %v", err) + } - h := NewHandler(nil, reg, agent, tasks, nil) + h := NewHandler(nil, reg, agent, tasks, nil) - req, _ := http.NewRequest("GET", "/api/tasks", nil) - rr := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/tasks", nil) + rr := httptest.NewRecorder() - h.ListTasks(rr, req) + h.ListTasks(rr, req) - if rr.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", rr.Code) - } + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } - var resp map[string][]models.Task - json.Unmarshal(rr.Body.Bytes(), &resp) + var resp map[string][]models.Task + json.Unmarshal(rr.Body.Bytes(), &resp) - if len(resp["items"]) < 1 { - t.Errorf("expected at least 1 item, got %d", len(resp["items"])) - } + if len(resp["items"]) < 1 { + t.Errorf("expected at least 1 item, got %d", len(resp["items"])) + } } func TestHandler_ProxyAgentMapsConfigStatus(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/v1/config/status" { - t.Fatalf("expected /v1/config/status, got %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"metadata":{"config_id":"local_3588_face_debug"}}`)) - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/config/status" { + t.Fatalf("expected /v1/config/status, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"metadata":{"config_id":"local_3588_face_debug"}}`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) - h := NewHandler(nil, reg, agent, nil, nil) + cfg := &config.Config{} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) + h := NewHandler(nil, reg, agent, nil, nil) - r := chi.NewRouter() - r.Get("/api/devices/{id}/config/status", h.ProxyAgent) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/devices/edge-01/config/status", nil)) + r := chi.NewRouter() + r.Get("/api/devices/{id}/config/status", h.ProxyAgent) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/devices/edge-01/config/status", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) - } - if !strings.Contains(rr.Body.String(), "local_3588_face_debug") { - t.Fatalf("expected config status response, got %s", rr.Body.String()) - } + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "local_3588_face_debug") { + t.Fatalf("expected config status response, got %s", rr.Body.String()) + } } func TestHandler_ProxyAgentMapsConfigCandidate(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - t.Fatalf("expected PUT, got %s", r.Method) - } - if r.URL.Path != "/v1/config/candidate" { - t.Fatalf("expected /v1/config/candidate, got %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"path":"/tmp/media-server.json.candidate.json"}`)) - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/v1/config/candidate" { + t.Fatalf("expected /v1/config/candidate, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"path":"/tmp/media-server.json.candidate.json"}`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) - h := NewHandler(nil, reg, agent, nil, nil) + cfg := &config.Config{} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) + h := NewHandler(nil, reg, agent, nil, nil) - r := chi.NewRouter() - r.Put("/api/devices/{id}/config/candidate", h.ProxyAgent) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, httptest.NewRequest(http.MethodPut, "/api/devices/edge-01/config/candidate", strings.NewReader(`{"metadata":{"config_id":"preview"},"templates":{"t":{}},"instances":[]}`))) + r := chi.NewRouter() + r.Put("/api/devices/{id}/config/candidate", h.ProxyAgent) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodPut, "/api/devices/edge-01/config/candidate", strings.NewReader(`{"metadata":{"config_id":"preview"},"templates":{"t":{}},"instances":[]}`))) - if rr.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) - } - if !strings.Contains(rr.Body.String(), "candidate") { - t.Fatalf("expected candidate response, got %s", rr.Body.String()) - } + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "candidate") { + t.Fatalf("expected candidate response, got %s", rr.Body.String()) + } } func TestHandler_ProxyAgentMapsConfigCandidateApply(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/v1/config/candidate/apply" { - t.Fatalf("expected /v1/config/candidate/apply, got %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"status":{"metadata":{"config_id":"candidate"}}}`)) - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/config/candidate/apply" { + t.Fatalf("expected /v1/config/candidate/apply, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"status":{"metadata":{"config_id":"candidate"}}}`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) - h := NewHandler(nil, reg, agent, nil, nil) + cfg := &config.Config{} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) + h := NewHandler(nil, reg, agent, nil, nil) - r := chi.NewRouter() - r.Post("/api/devices/{id}/config/candidate/apply", h.ProxyAgent) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/devices/edge-01/config/candidate/apply", nil)) + r := chi.NewRouter() + r.Post("/api/devices/{id}/config/candidate/apply", h.ProxyAgent) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/devices/edge-01/config/candidate/apply", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) - } - if !strings.Contains(rr.Body.String(), "candidate") { - t.Fatalf("expected candidate apply response, got %s", rr.Body.String()) - } + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "candidate") { + t.Fatalf("expected candidate apply response, got %s", rr.Body.String()) + } } func TestHandler_ProxyAgentMapsLongRunningConfigCandidateApply(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/v1/config/candidate/apply" { - t.Fatalf("expected /v1/config/candidate/apply, got %s", r.URL.Path) - } - time.Sleep(3500 * time.Millisecond) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"status":{"metadata":{"config_id":"candidate"}}}`)) - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/config/candidate/apply" { + t.Fatalf("expected /v1/config/candidate/apply, got %s", r.URL.Path) + } + time.Sleep(3500 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"status":{"metadata":{"config_id":"candidate"}}}`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) - h := NewHandler(nil, reg, agent, nil, nil) + cfg := &config.Config{} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) + h := NewHandler(nil, reg, agent, nil, nil) - r := chi.NewRouter() - r.Post("/api/devices/{id}/config/candidate/apply", h.ProxyAgent) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/devices/edge-01/config/candidate/apply", nil)) + r := chi.NewRouter() + r.Post("/api/devices/{id}/config/candidate/apply", h.ProxyAgent) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/devices/edge-01/config/candidate/apply", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) - } - if !strings.Contains(rr.Body.String(), "candidate") { - t.Fatalf("expected candidate apply response, got %s", rr.Body.String()) - } + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "candidate") { + t.Fatalf("expected candidate apply response, got %s", rr.Body.String()) + } } func TestHandler_ProxyAgentMapsLongRunningRollback(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/v1/media-server/rollback" { - t.Fatalf("expected /v1/media-server/rollback, got %s", r.URL.Path) - } - time.Sleep(3500 * time.Millisecond) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/media-server/rollback" { + t.Fatalf("expected /v1/media-server/rollback, got %s", r.URL.Path) + } + time.Sleep(3500 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) - h := NewHandler(nil, reg, agent, nil, nil) + cfg := &config.Config{} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) + h := NewHandler(nil, reg, agent, nil, nil) - r := chi.NewRouter() - r.Post("/api/devices/{id}/rollback", h.ProxyAgent) - rr := httptest.NewRecorder() - r.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/devices/edge-01/rollback", nil)) + r := chi.NewRouter() + r.Post("/api/devices/{id}/rollback", h.ProxyAgent) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/devices/edge-01/rollback", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) - } + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rr.Code, rr.Body.String()) + } } diff --git a/internal/api/openapi.go b/internal/api/openapi.go index faee927..f2b49de 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -1,287 +1,287 @@ package api import ( - "encoding/json" - "net/http" + "encoding/json" + "net/http" ) func OpenAPI(w http.ResponseWriter, r *http.Request) { - spec := map[string]any{ - "openapi": "3.0.3", - "info": map[string]any{ - "title": "managerd API", - "version": "v1", - }, - "paths": map[string]any{ - "/health": map[string]any{ - "get": map[string]any{ - "responses": map[string]any{ - "200": map[string]any{ - "description": "ok", - "content": map[string]any{"text/plain": map[string]any{}}, - }, - }, - }, - }, - "/api/discovery/search": map[string]any{ - "post": map[string]any{ - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{ - "application/json": map[string]any{ - "schema": map[string]any{ - "type": "object", - "properties": map[string]any{"timeout_ms": map[string]any{"type": "integer"}}, - }, - }, - }, - }, - "responses": map[string]any{ - "200": map[string]any{ - "description": "devices", - "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, - }, - }, - }, - }, - "/api/devices": map[string]any{ - "get": map[string]any{ - "responses": map[string]any{ - "200": map[string]any{ - "description": "devices", - "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, - }, - }, - }, - "post": map[string]any{ - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{ - "application/json": map[string]any{ - "schema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "device_id": map[string]any{"type": "string"}, - "device_name": map[string]any{"type": "string"}, - "ip": map[string]any{"type": "string"}, - "agent_port": map[string]any{"type": "integer"}, - "media_port": map[string]any{"type": "integer"}, - }, - "required": []any{"device_id", "ip"}, - }, - }, - }, - }, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "device", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/info": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "agent info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/reload": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/rollback": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/media-server/start": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/media-server/restart": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/media-server/stop": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/media-server/status": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "status", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/graphs": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "graphs", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/graphs/{name}": map[string]any{ - "get": map[string]any{ - "parameters": []any{ - map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}, - map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}, - }, - "responses": map[string]any{"200": map[string]any{"description": "graph", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/logs": map[string]any{ - "get": map[string]any{ - "parameters": []any{ - map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}, - map[string]any{"name": "limit", "in": "query", "required": false, "schema": map[string]any{"type": "integer"}}, - }, - "responses": map[string]any{"200": map[string]any{"description": "logs", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/config/apply": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, - }, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/models": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "models", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/models/upload": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{ - "multipart/form-data": map[string]any{ - "schema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "name": map[string]any{"type": "string"}, - "file": map[string]any{"type": "string", "format": "binary"}, - }, - "required": []any{"name", "file"}, - }, - }, - }, - }, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/v1/config": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "config", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - "put": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, - }, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/v1/config/ui/schema": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "schema", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/v1/config/ui/state": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "state", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/v1/config/ui/plan": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, - }, - "responses": map[string]any{"200": map[string]any{"description": "plan", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/devices/{id}/v1/config/ui/apply": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, - }, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/v1/face-gallery": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "face gallery info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - "put": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{"application/octet-stream": map[string]any{"schema": map[string]any{"type": "string", "format": "binary"}}}, - }, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/devices/{id}/v1/face-gallery/reload": map[string]any{ - "post": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "ok"}}, - }, - }, - "/api/templates": map[string]any{ - "get": map[string]any{ - "responses": map[string]any{"200": map[string]any{"description": "templates", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "array"}}}}}, - }, - }, - "/api/templates/{name}": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "template", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/tasks": map[string]any{ - "get": map[string]any{ - "responses": map[string]any{"200": map[string]any{"description": "tasks", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - "post": map[string]any{ - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, - }, - "responses": map[string]any{"200": map[string]any{"description": "task_id", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, - }, - }, - "/api/tasks/{id}/events": map[string]any{ - "get": map[string]any{ - "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, - "responses": map[string]any{"200": map[string]any{"description": "SSE", "content": map[string]any{"text/event-stream": map[string]any{}}}}, - }, - }, - }, - } + spec := map[string]any{ + "openapi": "3.0.3", + "info": map[string]any{ + "title": "managerd API", + "version": "v1", + }, + "paths": map[string]any{ + "/health": map[string]any{ + "get": map[string]any{ + "responses": map[string]any{ + "200": map[string]any{ + "description": "ok", + "content": map[string]any{"text/plain": map[string]any{}}, + }, + }, + }, + }, + "/api/discovery/search": map[string]any{ + "post": map[string]any{ + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{"timeout_ms": map[string]any{"type": "integer"}}, + }, + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "devices", + "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, + }, + }, + }, + }, + "/api/devices": map[string]any{ + "get": map[string]any{ + "responses": map[string]any{ + "200": map[string]any{ + "description": "devices", + "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, + }, + }, + }, + "post": map[string]any{ + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "device_id": map[string]any{"type": "string"}, + "device_name": map[string]any{"type": "string"}, + "ip": map[string]any{"type": "string"}, + "agent_port": map[string]any{"type": "integer"}, + "media_port": map[string]any{"type": "integer"}, + }, + "required": []any{"device_id", "ip"}, + }, + }, + }, + }, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "device", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/info": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "agent info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/reload": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/rollback": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/media-server/start": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/media-server/restart": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/media-server/stop": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/media-server/status": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "status", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/graphs": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "graphs", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/graphs/{name}": map[string]any{ + "get": map[string]any{ + "parameters": []any{ + map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}, + map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}, + }, + "responses": map[string]any{"200": map[string]any{"description": "graph", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/logs": map[string]any{ + "get": map[string]any{ + "parameters": []any{ + map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}, + map[string]any{"name": "limit", "in": "query", "required": false, "schema": map[string]any{"type": "integer"}}, + }, + "responses": map[string]any{"200": map[string]any{"description": "logs", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/config/apply": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, + }, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/models": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "models", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/models/upload": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "multipart/form-data": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "file": map[string]any{"type": "string", "format": "binary"}, + }, + "required": []any{"name", "file"}, + }, + }, + }, + }, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/v1/config": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "config", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + "put": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, + }, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/v1/config/ui/schema": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "schema", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/v1/config/ui/state": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "state", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/v1/config/ui/plan": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, + }, + "responses": map[string]any{"200": map[string]any{"description": "plan", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/devices/{id}/v1/config/ui/apply": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, + }, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/v1/face-gallery": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "face gallery info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + "put": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{"application/octet-stream": map[string]any{"schema": map[string]any{"type": "string", "format": "binary"}}}, + }, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/devices/{id}/v1/face-gallery/reload": map[string]any{ + "post": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "ok"}}, + }, + }, + "/api/templates": map[string]any{ + "get": map[string]any{ + "responses": map[string]any{"200": map[string]any{"description": "templates", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "array"}}}}}, + }, + }, + "/api/templates/{name}": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "template", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/tasks": map[string]any{ + "get": map[string]any{ + "responses": map[string]any{"200": map[string]any{"description": "tasks", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + "post": map[string]any{ + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}, + }, + "responses": map[string]any{"200": map[string]any{"description": "task_id", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}}, + }, + }, + "/api/tasks/{id}/events": map[string]any{ + "get": map[string]any{ + "parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, + "responses": map[string]any{"200": map[string]any{"description": "SSE", "content": map[string]any{"text/event-stream": map[string]any{}}}}, + }, + }, + }, + } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(spec) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(spec) } diff --git a/internal/config/config.go b/internal/config/config.go index 16b3394..59d159b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,72 +1,72 @@ package config import ( - "encoding/json" - "os" - "path/filepath" - "strings" + "encoding/json" + "os" + "path/filepath" + "strings" ) type Config struct { - Listen string `json:"listen"` - DiscoveryPort int `json:"discovery_port"` - DiscoveryTimeoutMs int `json:"discovery_timeout_ms"` - OfflineAfterMs int `json:"offline_after_ms"` - AgentToken string `json:"agent_token"` - Concurrency int `json:"concurrency"` - DataDir string `json:"data_dir,omitempty"` - DBPath string `json:"db_path,omitempty"` - LogDir string `json:"log_dir,omitempty"` - MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering - DeviceAliases map[string]string `json:"device_aliases,omitempty"` - path string + Listen string `json:"listen"` + DiscoveryPort int `json:"discovery_port"` + DiscoveryTimeoutMs int `json:"discovery_timeout_ms"` + OfflineAfterMs int `json:"offline_after_ms"` + AgentToken string `json:"agent_token"` + Concurrency int `json:"concurrency"` + DataDir string `json:"data_dir,omitempty"` + DBPath string `json:"db_path,omitempty"` + LogDir string `json:"log_dir,omitempty"` + MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering + DeviceAliases map[string]string `json:"device_aliases,omitempty"` + path string } func LoadConfig(path string) (*Config, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() - var cfg Config - decoder := json.NewDecoder(file) - if err := decoder.Decode(&cfg); err != nil { - return nil, err - } - cfg.path = path + var cfg Config + decoder := json.NewDecoder(file) + if err := decoder.Decode(&cfg); err != nil { + return nil, err + } + cfg.path = path - return &cfg, nil + return &cfg, nil } func (c *Config) Save() error { - if c == nil || c.path == "" { - return nil - } - body, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err - } - return os.WriteFile(c.path, append(body, '\n'), 0o644) + if c == nil || c.path == "" { + return nil + } + body, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.path, append(body, '\n'), 0o644) } func (c *Config) DataDirOrDefault() string { - if c != nil && strings.TrimSpace(c.DataDir) != "" { - return filepath.Clean(strings.TrimSpace(c.DataDir)) - } - return "data" + if c != nil && strings.TrimSpace(c.DataDir) != "" { + return filepath.Clean(strings.TrimSpace(c.DataDir)) + } + return "data" } func (c *Config) DBPathOrDefault() string { - if c != nil && strings.TrimSpace(c.DBPath) != "" { - return filepath.Clean(strings.TrimSpace(c.DBPath)) - } - return filepath.Join(c.DataDirOrDefault(), "app.db") + if c != nil && strings.TrimSpace(c.DBPath) != "" { + return filepath.Clean(strings.TrimSpace(c.DBPath)) + } + return filepath.Join(c.DataDirOrDefault(), "app.db") } func (c *Config) LogDirOrDefault() string { - if c != nil && strings.TrimSpace(c.LogDir) != "" { - return filepath.Clean(strings.TrimSpace(c.LogDir)) - } - return filepath.Join(c.DataDirOrDefault(), "logs") + if c != nil && strings.TrimSpace(c.LogDir) != "" { + return filepath.Clean(strings.TrimSpace(c.LogDir)) + } + return filepath.Join(c.DataDirOrDefault(), "logs") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c6490b0..b738624 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,20 +1,20 @@ package config import ( - "path/filepath" - "testing" + "path/filepath" + "testing" ) func TestConfigDefaultsLocalDataPaths(t *testing.T) { - cfg := &Config{} + cfg := &Config{} - if got := cfg.DataDirOrDefault(); got != "data" { - t.Fatalf("expected default data dir data, got %q", got) - } - if got := cfg.DBPathOrDefault(); got != filepath.Join("data", "app.db") { - t.Fatalf("expected default db path %q, got %q", filepath.Join("data", "app.db"), got) - } - if got := cfg.LogDirOrDefault(); got != filepath.Join("data", "logs") { - t.Fatalf("expected default log dir %q, got %q", filepath.Join("data", "logs"), got) - } + if got := cfg.DataDirOrDefault(); got != "data" { + t.Fatalf("expected default data dir data, got %q", got) + } + if got := cfg.DBPathOrDefault(); got != filepath.Join("data", "app.db") { + t.Fatalf("expected default db path %q, got %q", filepath.Join("data", "app.db"), got) + } + if got := cfg.LogDirOrDefault(); got != filepath.Join("data", "logs") { + t.Fatalf("expected default log dir %q, got %q", filepath.Join("data", "logs"), got) + } } diff --git a/internal/models/device.go b/internal/models/device.go index 94d8487..ecf17ce 100644 --- a/internal/models/device.go +++ b/internal/models/device.go @@ -3,63 +3,63 @@ package models import "sync" type Device struct { - DeviceID string `json:"device_id"` - Hostname string `json:"hostname,omitempty"` - IP string `json:"ip"` - AgentPort int `json:"agent_port"` - MediaPort int `json:"media_port"` - DeviceAlias string `json:"device_alias,omitempty"` - DeviceName string `json:"device_name"` - InstanceName string `json:"instance_name,omitempty"` - InstanceDisplayName string `json:"instance_display_name,omitempty"` - Version string `json:"version"` - BuildID string `json:"build_id,omitempty"` - GitSha string `json:"git_sha"` - UptimeSec int64 `json:"uptime_sec,omitempty"` - LastSeenMs int64 `json:"last_seen_ms"` - Online bool `json:"online"` - Graphs interface{} `json:"graphs,omitempty"` // 摘要或详情 + DeviceID string `json:"device_id"` + Hostname string `json:"hostname,omitempty"` + IP string `json:"ip"` + AgentPort int `json:"agent_port"` + MediaPort int `json:"media_port"` + DeviceAlias string `json:"device_alias,omitempty"` + DeviceName string `json:"device_name"` + InstanceName string `json:"instance_name,omitempty"` + InstanceDisplayName string `json:"instance_display_name,omitempty"` + Version string `json:"version"` + BuildID string `json:"build_id,omitempty"` + GitSha string `json:"git_sha"` + UptimeSec int64 `json:"uptime_sec,omitempty"` + LastSeenMs int64 `json:"last_seen_ms"` + Online bool `json:"online"` + Graphs interface{} `json:"graphs,omitempty"` // 摘要或详情 } type DeviceRegistry struct { - Mu sync.RWMutex - Devices map[string]*Device + Mu sync.RWMutex + Devices map[string]*Device } func NewDeviceRegistry() *DeviceRegistry { - return &DeviceRegistry{ - Devices: make(map[string]*Device), - } + return &DeviceRegistry{ + Devices: make(map[string]*Device), + } } func (d *Device) DisplayName() string { - if d == nil { - return "" - } - if d.DeviceAlias != "" { - return d.DeviceAlias - } - if d.DeviceName != "" { - return d.DeviceName - } - if d.Hostname != "" { - return d.Hostname - } - if d.DeviceID != "" { - return d.DeviceID - } - return "-" + if d == nil { + return "" + } + if d.DeviceAlias != "" { + return d.DeviceAlias + } + if d.DeviceName != "" { + return d.DeviceName + } + if d.Hostname != "" { + return d.Hostname + } + if d.DeviceID != "" { + return d.DeviceID + } + return "-" } func (d *Device) TechnicalName() string { - if d == nil { - return "" - } - if d.Hostname != "" && d.Hostname != d.DeviceAlias { - return d.Hostname - } - if d.DeviceName != "" && d.DeviceName != d.DeviceAlias { - return d.DeviceName - } - return "" + if d == nil { + return "" + } + if d.Hostname != "" && d.Hostname != d.DeviceAlias { + return d.Hostname + } + if d.DeviceName != "" && d.DeviceName != d.DeviceAlias { + return d.DeviceName + } + return "" } diff --git a/internal/models/task.go b/internal/models/task.go index 07a0e95..9845ed6 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -1,49 +1,49 @@ package models import ( - "sync" + "sync" ) type TaskStatus string const ( - TaskPending TaskStatus = "pending" - TaskRunning TaskStatus = "running" - TaskSuccess TaskStatus = "success" - TaskFailed TaskStatus = "failed" + TaskPending TaskStatus = "pending" + TaskRunning TaskStatus = "running" + TaskSuccess TaskStatus = "success" + TaskFailed TaskStatus = "failed" ) type DeviceTaskStatus struct { - DeviceID string `json:"device_id"` - Status TaskStatus `json:"status"` - Progress float64 `json:"progress"` - Error string `json:"error"` + DeviceID string `json:"device_id"` + Status TaskStatus `json:"status"` + Progress float64 `json:"progress"` + Error string `json:"error"` } type Task struct { - ID string `json:"task_id"` - Type string `json:"type"` - DeviceIDs []string `json:"device_ids"` - Payload interface{} `json:"payload"` - Status TaskStatus `json:"status"` - Devices map[string]*DeviceTaskStatus `json:"devices"` - Mu sync.RWMutex `json:"-"` + ID string `json:"task_id"` + Type string `json:"type"` + DeviceIDs []string `json:"device_ids"` + Payload interface{} `json:"payload"` + Status TaskStatus `json:"status"` + Devices map[string]*DeviceTaskStatus `json:"devices"` + Mu sync.RWMutex `json:"-"` } func NewTask(id, t string, deviceIDs []string, payload interface{}) *Task { - devices := make(map[string]*DeviceTaskStatus) - for _, did := range deviceIDs { - devices[did] = &DeviceTaskStatus{ - DeviceID: did, - Status: TaskPending, - } - } - return &Task{ - ID: id, - Type: t, - DeviceIDs: deviceIDs, - Payload: payload, - Status: TaskPending, - Devices: devices, - } + devices := make(map[string]*DeviceTaskStatus) + for _, did := range deviceIDs { + devices[did] = &DeviceTaskStatus{ + DeviceID: did, + Status: TaskPending, + } + } + return &Task{ + ID: id, + Type: t, + DeviceIDs: deviceIDs, + Payload: payload, + Status: TaskPending, + Devices: devices, + } } diff --git a/internal/service/agent_client.go b/internal/service/agent_client.go index 79e36ee..28d4b58 100644 --- a/internal/service/agent_client.go +++ b/internal/service/agent_client.go @@ -1,125 +1,125 @@ package service import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "strings" - "time" + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" - "3588AdminBackend/internal/config" + "3588AdminBackend/internal/config" ) type AgentClient struct { - cfg *config.Config - client *http.Client - upload *http.Client + cfg *config.Config + client *http.Client + upload *http.Client } func NewAgentClient(cfg *config.Config) *AgentClient { - return &AgentClient{ - cfg: cfg, - client: &http.Client{ - Timeout: 3 * time.Second, - }, - upload: &http.Client{Timeout: 120 * time.Second}, - } + return &AgentClient{ + cfg: cfg, + client: &http.Client{ + Timeout: 3 * time.Second, + }, + upload: &http.Client{Timeout: 120 * time.Second}, + } } func (c *AgentClient) Do(method, ip string, port int, path string, body []byte) ([]byte, int, error) { - return c.DoStream(method, ip, port, path, bytes.NewReader(body), "", int64(len(body))) + return c.DoStream(method, ip, port, path, bytes.NewReader(body), "", int64(len(body))) } func (c *AgentClient) DoStream(method, ip string, port int, path string, body io.Reader, contentType string, contentLength int64) ([]byte, int, error) { - url := fmt.Sprintf("http://%s:%d%s", ip, port, path) - var rc io.ReadCloser - if body == nil { - rc = http.NoBody - contentLength = 0 - } else if r, ok := body.(io.ReadCloser); ok { - rc = r - } else { - rc = io.NopCloser(body) - } + url := fmt.Sprintf("http://%s:%d%s", ip, port, path) + var rc io.ReadCloser + if body == nil { + rc = http.NoBody + contentLength = 0 + } else if r, ok := body.(io.ReadCloser); ok { + rc = r + } else { + rc = io.NopCloser(body) + } - ctx := context.Background() - req, err := http.NewRequestWithContext(ctx, method, url, rc) - if err != nil { - return nil, 0, err - } - if contentLength >= 0 { - req.ContentLength = contentLength - } + ctx := context.Background() + req, err := http.NewRequestWithContext(ctx, method, url, rc) + if err != nil { + return nil, 0, err + } + if contentLength >= 0 { + req.ContentLength = contentLength + } - if c.cfg.AgentToken != "" { - req.Header.Set("X-RK-Token", c.cfg.AgentToken) - } + if c.cfg.AgentToken != "" { + req.Header.Set("X-RK-Token", c.cfg.AgentToken) + } - if req.Body != nil && req.Body != http.NoBody { - ct := strings.TrimSpace(contentType) - if ct == "" { - ct = defaultContentTypeForPath(path) - } - if ct != "" { - req.Header.Set("Content-Type", ct) - } - } + if req.Body != nil && req.Body != http.NoBody { + ct := strings.TrimSpace(contentType) + if ct == "" { + ct = defaultContentTypeForPath(path) + } + if ct != "" { + req.Header.Set("Content-Type", ct) + } + } - client := c.client - if isLongOp(method, path) { - client = c.upload - } + client := c.client + if isLongOp(method, path) { + client = c.upload + } - resp, err := client.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, resp.StatusCode, err - } + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } - return respBody, resp.StatusCode, nil + return respBody, resp.StatusCode, nil } func defaultContentTypeForPath(path string) string { - switch { - case strings.HasPrefix(path, "/v1/models/"): - return "application/octet-stream" - case strings.HasPrefix(path, "/v1/resources/"): - return "application/octet-stream" - case path == "/v1/face-gallery": - return "application/octet-stream" - default: - return "application/json" - } + switch { + case strings.HasPrefix(path, "/v1/models/"): + return "application/octet-stream" + case strings.HasPrefix(path, "/v1/resources/"): + return "application/octet-stream" + case path == "/v1/face-gallery": + return "application/octet-stream" + default: + return "application/json" + } } func isLongOp(method, path string) bool { - if method == http.MethodPut && path == "/v1/config" { - return true - } - if method == http.MethodPost && path == "/v1/config/candidate/apply" { - return true - } - if method == http.MethodPost && path == "/v1/media-server/rollback" { - return true - } - if strings.HasPrefix(path, "/v1/config/ui/") { - return true - } - if strings.HasPrefix(path, "/v1/models/") { - return true - } - if strings.HasPrefix(path, "/v1/resources/") { - return true - } - if path == "/v1/face-gallery" { - return true - } - return false + if method == http.MethodPut && path == "/v1/config" { + return true + } + if method == http.MethodPost && path == "/v1/config/candidate/apply" { + return true + } + if method == http.MethodPost && path == "/v1/media-server/rollback" { + return true + } + if strings.HasPrefix(path, "/v1/config/ui/") { + return true + } + if strings.HasPrefix(path, "/v1/models/") { + return true + } + if strings.HasPrefix(path, "/v1/resources/") { + return true + } + if path == "/v1/face-gallery" { + return true + } + return false } diff --git a/internal/service/alarm_collector.go b/internal/service/alarm_collector.go index 1af7c9a..3ef453f 100644 --- a/internal/service/alarm_collector.go +++ b/internal/service/alarm_collector.go @@ -1,211 +1,211 @@ package service import ( - "database/sql" - "encoding/json" - "fmt" - "log" - "sync" - "time" + "database/sql" + "encoding/json" + "fmt" + "log" + "sync" + "time" - "3588AdminBackend/internal/models" + "3588AdminBackend/internal/models" ) type AlarmRecord struct { - ID string `json:"id"` - DeviceID string `json:"device_id"` - Channel string `json:"channel"` - Timestamp string `json:"timestamp"` - RuleName string `json:"rule_name"` - RuleType string `json:"rule_type"` - ObjectLabel string `json:"object_label"` - Confidence float64 `json:"confidence"` - SnapshotURL string `json:"snapshot_url"` - ClipURL string `json:"clip_url"` - DurationMs int64 `json:"duration_ms"` - CollectedAt string `json:"collected_at"` + ID string `json:"id"` + DeviceID string `json:"device_id"` + Channel string `json:"channel"` + Timestamp string `json:"timestamp"` + RuleName string `json:"rule_name"` + RuleType string `json:"rule_type"` + ObjectLabel string `json:"object_label"` + Confidence float64 `json:"confidence"` + SnapshotURL string `json:"snapshot_url"` + ClipURL string `json:"clip_url"` + DurationMs int64 `json:"duration_ms"` + CollectedAt string `json:"collected_at"` } type AlarmCollector struct { - db *sql.DB - agent *AgentClient - registry *RegistryService - mu sync.Mutex - lastID string // last alarm ID seen, to avoid duplicates + db *sql.DB + agent *AgentClient + registry *RegistryService + mu sync.Mutex + lastID string // last alarm ID seen, to avoid duplicates } func NewAlarmCollector(db *sql.DB, agent *AgentClient, registry *RegistryService) *AlarmCollector { - return &AlarmCollector{db: db, agent: agent, registry: registry} + return &AlarmCollector{db: db, agent: agent, registry: registry} } func (c *AlarmCollector) Start() { - go c.poll() + go c.poll() } func (c *AlarmCollector) poll() { - // Initial delay to let the system settle - time.Sleep(5 * time.Second) + // Initial delay to let the system settle + time.Sleep(5 * time.Second) - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() - for range ticker.C { - c.collectFromDevices() - } + for range ticker.C { + c.collectFromDevices() + } } func (c *AlarmCollector) collectFromDevices() { - if c.agent == nil || c.registry == nil { - return - } - devices := c.registry.GetDevices() - for _, dev := range devices { - if dev == nil || !dev.Online { - continue - } - alarms, err := c.fetchDeviceAlarms(dev) - if err != nil { - continue - } - for _, alarm := range alarms { - alarm.DeviceID = dev.DeviceID - alarm.CollectedAt = time.Now().Format(time.RFC3339) - c.saveAlarm(alarm) - } - } + if c.agent == nil || c.registry == nil { + return + } + devices := c.registry.GetDevices() + for _, dev := range devices { + if dev == nil || !dev.Online { + continue + } + alarms, err := c.fetchDeviceAlarms(dev) + if err != nil { + continue + } + for _, alarm := range alarms { + alarm.DeviceID = dev.DeviceID + alarm.CollectedAt = time.Now().Format(time.RFC3339) + c.saveAlarm(alarm) + } + } } func (c *AlarmCollector) fetchDeviceAlarms(dev *models.Device) ([]AlarmRecord, error) { - c.mu.Lock() - lastID := c.lastID - c.mu.Unlock() + c.mu.Lock() + lastID := c.lastID + c.mu.Unlock() - url := "/v1/alarms/recent?limit=100" - body, status, err := c.agent.Do("GET", dev.IP, dev.AgentPort, url, nil) - if err != nil { - return nil, err - } - if status != 200 { - return nil, nil // agent may not support alarms yet - } + url := "/v1/alarms/recent?limit=100" + body, status, err := c.agent.Do("GET", dev.IP, dev.AgentPort, url, nil) + if err != nil { + return nil, err + } + if status != 200 { + return nil, nil // agent may not support alarms yet + } - var resp struct { - Alarms []map[string]any `json:"alarms"` - } - if err := json.Unmarshal(body, &resp); err != nil { - return nil, err - } + var resp struct { + Alarms []map[string]any `json:"alarms"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } - newLastID := lastID - alarms := make([]AlarmRecord, 0) - for _, a := range resp.Alarms { - id, _ := a["id"].(string) - if id == "" { - continue - } - if id == lastID { - break // reached previously seen alarms - } - // Extract fields from whatever format media-server sends - channel, _ := a["channel"].(string) - if channel == "" { - channel, _ = a["node_id"].(string) // media-server uses node_id - } - ruleName, _ := a["rule_name"].(string) - ruleType, _ := a["rule_type"].(string) - objectLabel, _ := a["object_label"].(string) - snapshotURL, _ := a["snapshot_url"].(string) - clipURL, _ := a["clip_url"].(string) - // Timestamp can be string (RFC3339) or number (unix millis) - var ts string - switch v := a["timestamp"].(type) { - case string: - ts = v - case float64: - ts = time.UnixMilli(int64(v)).Format(time.RFC3339) - } - confidence, _ := a["confidence"].(float64) - if confidence == 0 { - if score, ok := a["score"].(float64); ok { - confidence = score - } - } - // Extract from nested detections if top-level fields are empty - if dets, ok := a["detections"].([]any); ok && len(dets) > 0 { - if objectLabel == "" { - objectLabel = fmt.Sprintf("%d 个检测目标", len(dets)) - } - if confidence == 0 { - if d0, ok := dets[0].(map[string]any); ok { - if s, ok := d0["score"].(float64); ok { - confidence = s - } - } - } - } - durationMs, _ := a["duration_ms"].(float64) - alarms = append(alarms, AlarmRecord{ - ID: id, - Timestamp: ts, - Channel: channel, - RuleName: ruleName, - RuleType: ruleType, - ObjectLabel: objectLabel, - Confidence: confidence, - SnapshotURL: snapshotURL, - ClipURL: clipURL, - DurationMs: int64(durationMs), - }) - if newLastID == "" { - newLastID = id - } - } + newLastID := lastID + alarms := make([]AlarmRecord, 0) + for _, a := range resp.Alarms { + id, _ := a["id"].(string) + if id == "" { + continue + } + if id == lastID { + break // reached previously seen alarms + } + // Extract fields from whatever format media-server sends + channel, _ := a["channel"].(string) + if channel == "" { + channel, _ = a["node_id"].(string) // media-server uses node_id + } + ruleName, _ := a["rule_name"].(string) + ruleType, _ := a["rule_type"].(string) + objectLabel, _ := a["object_label"].(string) + snapshotURL, _ := a["snapshot_url"].(string) + clipURL, _ := a["clip_url"].(string) + // Timestamp can be string (RFC3339) or number (unix millis) + var ts string + switch v := a["timestamp"].(type) { + case string: + ts = v + case float64: + ts = time.UnixMilli(int64(v)).Format(time.RFC3339) + } + confidence, _ := a["confidence"].(float64) + if confidence == 0 { + if score, ok := a["score"].(float64); ok { + confidence = score + } + } + // Extract from nested detections if top-level fields are empty + if dets, ok := a["detections"].([]any); ok && len(dets) > 0 { + if objectLabel == "" { + objectLabel = fmt.Sprintf("%d 个检测目标", len(dets)) + } + if confidence == 0 { + if d0, ok := dets[0].(map[string]any); ok { + if s, ok := d0["score"].(float64); ok { + confidence = s + } + } + } + } + durationMs, _ := a["duration_ms"].(float64) + alarms = append(alarms, AlarmRecord{ + ID: id, + Timestamp: ts, + Channel: channel, + RuleName: ruleName, + RuleType: ruleType, + ObjectLabel: objectLabel, + Confidence: confidence, + SnapshotURL: snapshotURL, + ClipURL: clipURL, + DurationMs: int64(durationMs), + }) + if newLastID == "" { + newLastID = id + } + } - if newLastID != "" { - c.mu.Lock() - c.lastID = newLastID - c.mu.Unlock() - } - return alarms, nil + if newLastID != "" { + c.mu.Lock() + c.lastID = newLastID + c.mu.Unlock() + } + return alarms, nil } func (c *AlarmCollector) saveAlarm(alarm AlarmRecord) { - if c.db == nil { - return - } - _, err := c.db.Exec(` + if c.db == nil { + return + } + _, err := c.db.Exec(` INSERT INTO alarm_records(id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING `, alarm.ID, alarm.DeviceID, alarm.Channel, alarm.Timestamp, alarm.RuleName, alarm.RuleType, alarm.ObjectLabel, alarm.Confidence, alarm.SnapshotURL, alarm.ClipURL, alarm.DurationMs, alarm.CollectedAt) - if err != nil { - log.Printf("alarm collector: save error: %v", err) - } + if err != nil { + log.Printf("alarm collector: save error: %v", err) + } } // GetRecent returns the most recent N alarm records, newest first. func (c *AlarmCollector) GetRecent(limit int) []AlarmRecord { - if c.db == nil || limit <= 0 { - return nil - } - rows, err := c.db.Query(` + if c.db == nil || limit <= 0 { + return nil + } + rows, err := c.db.Query(` SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at FROM alarm_records ORDER BY timestamp DESC LIMIT ? `, limit) - if err != nil { - return nil - } - defer rows.Close() + if err != nil { + return nil + } + defer rows.Close() - var alarms []AlarmRecord - for rows.Next() { - var a AlarmRecord - if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt); err != nil { - continue - } - alarms = append(alarms, a) - } - return alarms + var alarms []AlarmRecord + for rows.Next() { + var a AlarmRecord + if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt); err != nil { + continue + } + alarms = append(alarms, a) + } + return alarms } diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index 0e02bcd..440bdac 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -1,1824 +1,1824 @@ package service import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" ) var legacyBuiltinTemplateAliases = map[string]string{ - "workshop_face_shoe_alarm": "std_workshop_face_recognition_shoe_alarm", - "std_service_smoke_stream": "std_service_test_stream", + "workshop_face_shoe_alarm": "std_workshop_face_recognition_shoe_alarm", + "std_service_smoke_stream": "std_service_test_stream", } type ConfigTemplateAsset struct { - Name string `json:"name"` - Path string `json:"path"` - Origin string `json:"origin"` - ReadOnly bool `json:"read_only"` - Description string `json:"description"` - Source string `json:"source"` - Slots TemplateSlotGroup `json:"slots"` - NodeCount int `json:"node_count"` - EdgeCount int `json:"edge_count"` - MinIOEndpoint string `json:"minio_endpoint"` - MinIOBucket string `json:"minio_bucket"` - ExternalGetTokenURL string `json:"external_get_token_url"` - ExternalPutMessageURL string `json:"external_put_message_url"` - TenantCode string `json:"tenant_code"` - AdvancedParams map[string]any `json:"advanced_params"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + Origin string `json:"origin"` + ReadOnly bool `json:"read_only"` + Description string `json:"description"` + Source string `json:"source"` + Slots TemplateSlotGroup `json:"slots"` + NodeCount int `json:"node_count"` + EdgeCount int `json:"edge_count"` + MinIOEndpoint string `json:"minio_endpoint"` + MinIOBucket string `json:"minio_bucket"` + ExternalGetTokenURL string `json:"external_get_token_url"` + ExternalPutMessageURL string `json:"external_put_message_url"` + TenantCode string `json:"tenant_code"` + AdvancedParams map[string]any `json:"advanced_params"` + Raw map[string]any `json:"raw"` } type ConfigProfileAsset struct { - Name string `json:"name"` - Path string `json:"path"` - Description string `json:"description"` - BusinessName string `json:"business_name"` - QueueSize int `json:"queue_size"` - QueueStrategy string `json:"queue_strategy"` - Instances []ConfigProfileInstanceAsset `json:"instances"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + Description string `json:"description"` + BusinessName string `json:"business_name"` + QueueSize int `json:"queue_size"` + QueueStrategy string `json:"queue_strategy"` + Instances []ConfigProfileInstanceAsset `json:"instances"` + Raw map[string]any `json:"raw"` } type ConfigProfileInstanceAsset struct { - Name string `json:"name"` - Template string `json:"template"` - VideoSourceRef string `json:"video_source_ref"` - DisplayName string `json:"display_name"` - DeviceCode string `json:"device_code"` - SiteName string `json:"site_name"` - PublishHLSPath string `json:"publish_hls_path"` - PublishRTSPPort string `json:"publish_rtsp_port"` - PublishRTSPPath string `json:"publish_rtsp_path"` - ChannelNo string `json:"channel_no"` - AdvancedParams map[string]any `json:"advanced_params"` + Name string `json:"name"` + Template string `json:"template"` + VideoSourceRef string `json:"video_source_ref"` + DisplayName string `json:"display_name"` + DeviceCode string `json:"device_code"` + SiteName string `json:"site_name"` + PublishHLSPath string `json:"publish_hls_path"` + PublishRTSPPort string `json:"publish_rtsp_port"` + PublishRTSPPath string `json:"publish_rtsp_path"` + ChannelNo string `json:"channel_no"` + AdvancedParams map[string]any `json:"advanced_params"` } type ConfigOverlayAsset struct { - Name string `json:"name"` - Path string `json:"path"` - Description string `json:"description"` - OverrideTargets []string `json:"override_targets"` - OverrideTargetNum int `json:"override_target_num"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + Description string `json:"description"` + OverrideTargets []string `json:"override_targets"` + OverrideTargetNum int `json:"override_target_num"` + Raw map[string]any `json:"raw"` } type ConfigIntegrationServiceAsset struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` - TypeLabel string `json:"type_label"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - AddressSummary string `json:"address_summary"` - RefCount int `json:"ref_count"` - ObjectStorage *ObjectStorageConfig `json:"object_storage,omitempty"` - TokenService *TokenServiceConfig `json:"token_service,omitempty"` - AlarmService *AlarmServiceConfig `json:"alarm_service,omitempty"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + TypeLabel string `json:"type_label"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + AddressSummary string `json:"address_summary"` + RefCount int `json:"ref_count"` + ObjectStorage *ObjectStorageConfig `json:"object_storage,omitempty"` + TokenService *TokenServiceConfig `json:"token_service,omitempty"` + AlarmService *AlarmServiceConfig `json:"alarm_service,omitempty"` + Raw map[string]any `json:"raw"` } type ObjectStorageConfig struct { - Endpoint string `json:"endpoint"` - Bucket string `json:"bucket"` - AccessKey string `json:"access_key"` - SecretKey string `json:"secret_key"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` } type TokenServiceConfig struct { - GetTokenURL string `json:"get_token_url"` - Username string `json:"username"` - Password string `json:"password"` - TenantCode string `json:"tenant_code"` + GetTokenURL string `json:"get_token_url"` + Username string `json:"username"` + Password string `json:"password"` + TenantCode string `json:"tenant_code"` } type AlarmServiceConfig struct { - PutMessageURL string `json:"put_message_url"` - Username string `json:"username"` - Password string `json:"password"` - TenantCode string `json:"tenant_code"` + PutMessageURL string `json:"put_message_url"` + Username string `json:"username"` + Password string `json:"password"` + TenantCode string `json:"tenant_code"` } type ConfigVideoSourceAsset struct { - Name string `json:"name"` - Path string `json:"path"` - SourceType string `json:"source_type"` - SourceTypeLabel string `json:"source_type_label"` - Area string `json:"area"` - Description string `json:"description"` - RefCount int `json:"ref_count"` - SourceSummary string `json:"source_summary"` - Config VideoSourceConfig `json:"config"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + SourceType string `json:"source_type"` + SourceTypeLabel string `json:"source_type_label"` + Area string `json:"area"` + Description string `json:"description"` + RefCount int `json:"ref_count"` + SourceSummary string `json:"source_summary"` + Config VideoSourceConfig `json:"config"` + Raw map[string]any `json:"raw"` } type RecognitionUnitAsset struct { - Ref string `json:"ref"` - SceneTemplateName string `json:"scene_template_name"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - SiteName string `json:"site_name"` - VideoSourceRef string `json:"video_source_ref"` - OutputChannel string `json:"output_channel"` - RTSPPort string `json:"rtsp_port"` - Description string `json:"description"` - AdvancedParams map[string]any `json:"advanced_params,omitempty"` + Ref string `json:"ref"` + SceneTemplateName string `json:"scene_template_name"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + SiteName string `json:"site_name"` + VideoSourceRef string `json:"video_source_ref"` + OutputChannel string `json:"output_channel"` + RTSPPort string `json:"rtsp_port"` + Description string `json:"description"` + AdvancedParams map[string]any `json:"advanced_params,omitempty"` } type DeviceAssignmentAsset struct { - DeviceID string `json:"device_id"` - ProfileName string `json:"profile_name"` - Description string `json:"description"` - RecognitionUnits []string `json:"recognition_units"` - RecognitionCount int `json:"recognition_count"` + DeviceID string `json:"device_id"` + ProfileName string `json:"profile_name"` + Description string `json:"description"` + RecognitionUnits []string `json:"recognition_units"` + RecognitionCount int `json:"recognition_count"` } type DeviceAssignmentBoardStats struct { - TotalUnits int `json:"total_units"` - TotalDevices int `json:"total_devices"` - AssignedUnits int `json:"assigned_units"` - UnassignedUnits int `json:"unassigned_units"` - AverageLoad float64 `json:"average_load"` - OverloadedDevices int `json:"overloaded_devices"` + TotalUnits int `json:"total_units"` + TotalDevices int `json:"total_devices"` + AssignedUnits int `json:"assigned_units"` + UnassignedUnits int `json:"unassigned_units"` + AverageLoad float64 `json:"average_load"` + OverloadedDevices int `json:"overloaded_devices"` } type DeviceAssignmentBoardUnit struct { - Ref string `json:"ref"` - SceneTemplateName string `json:"scene_template_name"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - VideoSourceRef string `json:"video_source_ref"` - OutputChannel string `json:"output_channel"` + Ref string `json:"ref"` + SceneTemplateName string `json:"scene_template_name"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + VideoSourceRef string `json:"video_source_ref"` + OutputChannel string `json:"output_channel"` } type DeviceAssignmentBoardCard struct { - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` - ProfileName string `json:"profile_name"` - AssignedCount int `json:"assigned_count"` - MaxUnits int `json:"max_units"` - Status string `json:"status"` - Units []DeviceAssignmentBoardUnit `json:"units"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + ProfileName string `json:"profile_name"` + AssignedCount int `json:"assigned_count"` + MaxUnits int `json:"max_units"` + Status string `json:"status"` + Units []DeviceAssignmentBoardUnit `json:"units"` } type DeviceAssignmentBoard struct { - MaxUnitsPerDevice int `json:"max_units_per_device"` - Stats DeviceAssignmentBoardStats `json:"stats"` - Cards []DeviceAssignmentBoardCard `json:"cards"` - Unassigned []DeviceAssignmentBoardUnit `json:"unassigned"` + MaxUnitsPerDevice int `json:"max_units_per_device"` + Stats DeviceAssignmentBoardStats `json:"stats"` + Cards []DeviceAssignmentBoardCard `json:"cards"` + Unassigned []DeviceAssignmentBoardUnit `json:"unassigned"` } type VideoSourceConfig struct { - URL string `json:"url"` - Resolution string `json:"resolution"` - FrameSize string `json:"frame_size"` - FPS string `json:"fps"` - VideoFormat string `json:"video_format"` - FocalLength string `json:"focal_length"` - MountHeight string `json:"mount_height"` - MountAngle string `json:"mount_angle"` + URL string `json:"url"` + Resolution string `json:"resolution"` + FrameSize string `json:"frame_size"` + FPS string `json:"fps"` + VideoFormat string `json:"video_format"` + FocalLength string `json:"focal_length"` + MountHeight string `json:"mount_height"` + MountAngle string `json:"mount_angle"` } func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) { - items := make([]ConfigTemplateAsset, 0) - seen := map[string]bool{} + items := make([]ConfigTemplateAsset, 0) + seen := map[string]bool{} - if s != nil && s.assets != nil { - records, err := s.assets.ListTemplates() - if err != nil { - return nil, err - } - for _, record := range records { - name := strings.TrimSpace(record.Name) - if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" { - continue - } - readOnly := isStandardTemplateName(name) - origin := "user" - if readOnly { - origin = "standard" - } - item, err := s.templateAssetFromRecord(record, origin, readOnly) - if err != nil { - continue - } - items = append(items, *item) - seen[item.Name] = true - } - } + if s != nil && s.assets != nil { + records, err := s.assets.ListTemplates() + if err != nil { + return nil, err + } + for _, record := range records { + name := strings.TrimSpace(record.Name) + if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" { + continue + } + readOnly := isStandardTemplateName(name) + origin := "user" + if readOnly { + origin = "standard" + } + item, err := s.templateAssetFromRecord(record, origin, readOnly) + if err != nil { + continue + } + items = append(items, *item) + seen[item.Name] = true + } + } - sort.Slice(items, func(i, j int) bool { - if items[i].ReadOnly != items[j].ReadOnly { - return items[i].ReadOnly - } - return items[i].Name < items[j].Name - }) - return items, nil + sort.Slice(items, func(i, j int) bool { + if items[i].ReadOnly != items[j].ReadOnly { + return items[i].ReadOnly + } + return items[i].Name < items[j].Name + }) + return items, nil } func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAsset, error) { - name = canonicalTemplateAssetName(name) - if err := validateConfigName(name); err != nil { - return nil, err - } - if s != nil && s.assets != nil { - record, err := s.assets.GetTemplate(name) - if err != nil { - return nil, err - } - if record != nil { - readOnly := isStandardTemplateName(name) - origin := "user" - if readOnly { - origin = "standard" - } - return s.templateAssetFromRecord(*record, origin, readOnly) - } - } - return nil, os.ErrNotExist + name = canonicalTemplateAssetName(name) + if err := validateConfigName(name); err != nil { + return nil, err + } + if s != nil && s.assets != nil { + record, err := s.assets.GetTemplate(name) + if err != nil { + return nil, err + } + if record != nil { + readOnly := isStandardTemplateName(name) + origin := "user" + if readOnly { + origin = "standard" + } + return s.templateAssetFromRecord(*record, origin, readOnly) + } + } + return nil, os.ErrNotExist } func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string, bodyJSON string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - name = canonicalTemplateAssetName(name) - if err := validateConfigName(name); err != nil { - return err - } - if isStandardTemplateName(name) { - return fmt.Errorf("standard template %q is read-only; please copy it before editing", name) - } - return s.assets.SaveTemplate(name, description, bodyJSON) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + name = canonicalTemplateAssetName(name) + if err := validateConfigName(name); err != nil { + return err + } + if isStandardTemplateName(name) { + return fmt.Errorf("standard template %q is read-only; please copy it before editing", name) + } + return s.assets.SaveTemplate(name, description, bodyJSON) } func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName string, description string, bodyJSON string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - oldName = canonicalTemplateAssetName(oldName) - newName = canonicalTemplateAssetName(newName) - if err := validateConfigName(oldName); err != nil { - return err - } - if err := validateConfigName(newName); err != nil { - return err - } - if isStandardTemplateName(oldName) { - return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName) - } - if oldName != newName && isStandardTemplateName(newName) { - return fmt.Errorf("standard template name %q is reserved", newName) - } - return s.assets.RenameTemplate(oldName, newName, description, bodyJSON) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + oldName = canonicalTemplateAssetName(oldName) + newName = canonicalTemplateAssetName(newName) + if err := validateConfigName(oldName); err != nil { + return err + } + if err := validateConfigName(newName); err != nil { + return err + } + if isStandardTemplateName(oldName) { + return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName) + } + if oldName != newName && isStandardTemplateName(newName) { + return fmt.Errorf("standard template name %q is reserved", newName) + } + return s.assets.RenameTemplate(oldName, newName, description, bodyJSON) } func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - name = canonicalTemplateAssetName(name) - if err := validateConfigName(name); err != nil { - return err - } - if isStandardTemplateName(name) { - return fmt.Errorf("standard template %q is read-only and cannot be deleted", name) - } - refs, err := s.profileNamesReferencingTemplate(name) - if err != nil { - return err - } - if len(refs) > 0 { - return fmt.Errorf("template %q is used by business configs: %s", name, strings.Join(refs, ", ")) - } - return s.assets.DeleteTemplate(name) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + name = canonicalTemplateAssetName(name) + if err := validateConfigName(name); err != nil { + return err + } + if isStandardTemplateName(name) { + return fmt.Errorf("standard template %q is read-only and cannot be deleted", name) + } + refs, err := s.profileNamesReferencingTemplate(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("template %q is used by business configs: %s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteTemplate(name) } func (s *ConfigPreviewService) ListProfileAssets() ([]ConfigProfileAsset, error) { - sources, err := s.ListSources() - if err != nil { - return nil, err - } - items := make([]ConfigProfileAsset, 0, len(sources.Profiles)) - for _, source := range sources.Profiles { - item, err := s.GetProfileAsset(source.Name) - if err != nil { - continue - } - items = append(items, *item) - } - return items, nil + sources, err := s.ListSources() + if err != nil { + return nil, err + } + items := make([]ConfigProfileAsset, 0, len(sources.Profiles)) + for _, source := range sources.Profiles { + item, err := s.GetProfileAsset(source.Name) + if err != nil { + continue + } + items = append(items, *item) + } + return items, nil } func (s *ConfigPreviewService) profileNamesReferencingTemplate(templateName string) ([]string, error) { - if s == nil || s.assets == nil { - return nil, nil - } - records, err := s.assets.ListProfiles() - if err != nil { - return nil, err - } - refs := make([]string, 0) - for _, record := range records { - if strings.TrimSpace(record.TemplateName) == templateName || strings.Contains(record.BodyJSON, `"`+"template"+`": "`+templateName+`"`) { - refs = append(refs, record.Name) - } - } - sort.Strings(refs) - return refs, nil + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListProfiles() + if err != nil { + return nil, err + } + refs := make([]string, 0) + for _, record := range records { + if strings.TrimSpace(record.TemplateName) == templateName || strings.Contains(record.BodyJSON, `"`+"template"+`": "`+templateName+`"`) { + refs = append(refs, record.Name) + } + } + sort.Strings(refs) + return refs, nil } func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset, error) { - raw, path, err := s.readAssetJSON("profiles", name) - if err != nil { - return nil, err - } - queueMap, _ := raw["queue"].(map[string]any) - units, err := s.ListRecognitionUnits() - if err != nil { - return nil, err - } - instances := make([]ConfigProfileInstanceAsset, 0) - for _, unit := range units { - if unit.SceneTemplateName != name { - continue - } - channel := firstString(unit.OutputChannel, unit.Name) - instances = append(instances, ConfigProfileInstanceAsset{ - Name: unit.Name, - Template: stringValue(raw["primary_template_name"]), - VideoSourceRef: unit.VideoSourceRef, - DisplayName: unit.DisplayName, - SiteName: unit.SiteName, - PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", - PublishRTSPPort: unit.RTSPPort, - PublishRTSPPath: "/live/" + channel, - ChannelNo: channel, - AdvancedParams: cloneMap(unit.AdvancedParams), - }) - } - return &ConfigProfileAsset{ - Name: firstString(raw["name"], name), - Path: path, - Description: stringValue(raw["description"]), - BusinessName: stringValue(raw["business_name"]), - QueueSize: intValue(queueMap["size"]), - QueueStrategy: stringValue(queueMap["strategy"]), - Instances: instances, - Raw: raw, - }, nil + raw, path, err := s.readAssetJSON("profiles", name) + if err != nil { + return nil, err + } + queueMap, _ := raw["queue"].(map[string]any) + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, err + } + instances := make([]ConfigProfileInstanceAsset, 0) + for _, unit := range units { + if unit.SceneTemplateName != name { + continue + } + channel := firstString(unit.OutputChannel, unit.Name) + instances = append(instances, ConfigProfileInstanceAsset{ + Name: unit.Name, + Template: stringValue(raw["primary_template_name"]), + VideoSourceRef: unit.VideoSourceRef, + DisplayName: unit.DisplayName, + SiteName: unit.SiteName, + PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", + PublishRTSPPort: unit.RTSPPort, + PublishRTSPPath: "/live/" + channel, + ChannelNo: channel, + AdvancedParams: cloneMap(unit.AdvancedParams), + }) + } + return &ConfigProfileAsset{ + Name: firstString(raw["name"], name), + Path: path, + Description: stringValue(raw["description"]), + BusinessName: stringValue(raw["business_name"]), + QueueSize: intValue(queueMap["size"]), + QueueStrategy: stringValue(queueMap["strategy"]), + Instances: instances, + Raw: raw, + }, nil } func (s *ConfigPreviewService) DeleteProfileAsset(name string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - if err := validateConfigName(name); err != nil { - return err - } - units, err := s.ListRecognitionUnits() - if err != nil { - return err - } - for _, unit := range units { - if unit.SceneTemplateName == name { - return fmt.Errorf("场景模板 %q 下仍有识别单元,无法删除", name) - } - } - return s.assets.DeleteProfile(name) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return err + } + units, err := s.ListRecognitionUnits() + if err != nil { + return err + } + for _, unit := range units { + if unit.SceneTemplateName == name { + return fmt.Errorf("场景模板 %q 下仍有识别单元,无法删除", name) + } + } + return s.assets.DeleteProfile(name) } func recognitionUnitRef(profileName string, unitName string) string { - return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) + return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) } func parseRecognitionUnitRef(ref string) (string, string, error) { - parts := strings.SplitN(strings.TrimSpace(ref), "::", 2) - if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { - return "", "", fmt.Errorf("invalid recognition unit ref: %s", ref) - } - return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil + parts := strings.SplitN(strings.TrimSpace(ref), "::", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return "", "", fmt.Errorf("invalid recognition unit ref: %s", ref) + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil } func (s *ConfigPreviewService) ListRecognitionUnits() ([]RecognitionUnitAsset, error) { - if s == nil || s.assets == nil { - return nil, fmt.Errorf("asset repository is not configured") - } - records, err := s.assets.ListRecognitionUnits() - if err != nil { - return nil, err - } - items := make([]RecognitionUnitAsset, 0, len(records)) - for _, record := range records { - item, err := recognitionUnitFromRecord(record) - if err != nil { - continue - } - items = append(items, *item) - } - sort.Slice(items, func(i, j int) bool { - if items[i].SceneTemplateName != items[j].SceneTemplateName { - return items[i].SceneTemplateName < items[j].SceneTemplateName - } - return items[i].Name < items[j].Name - }) - return items, nil + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + records, err := s.assets.ListRecognitionUnits() + if err != nil { + return nil, err + } + items := make([]RecognitionUnitAsset, 0, len(records)) + for _, record := range records { + item, err := recognitionUnitFromRecord(record) + if err != nil { + continue + } + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].SceneTemplateName != items[j].SceneTemplateName { + return items[i].SceneTemplateName < items[j].SceneTemplateName + } + return items[i].Name < items[j].Name + }) + return items, nil } func (s *ConfigPreviewService) GetRecognitionUnit(ref string) (*RecognitionUnitAsset, error) { - sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref) - if err != nil { - return nil, err - } - record, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName) - if err != nil { - return nil, err - } - if record == nil { - return nil, os.ErrNotExist - } - return recognitionUnitFromRecord(*record) + sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref) + if err != nil { + return nil, err + } + record, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + return recognitionUnitFromRecord(*record) } func (s *ConfigPreviewService) SaveRecognitionUnit(unit RecognitionUnitAsset, originalRef string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - sceneTemplate := strings.TrimSpace(unit.SceneTemplateName) - if err := validateConfigName(sceneTemplate); err != nil { - return fmt.Errorf("invalid scene template: %w", err) - } - unitName := strings.TrimSpace(unit.Name) - if err := validateConfigName(unitName); err != nil { - return fmt.Errorf("invalid recognition unit name: %w", err) - } - if strings.TrimSpace(unit.VideoSourceRef) == "" { - return fmt.Errorf("video source is required") - } - originalTemplate := sceneTemplate - originalName := unitName - if strings.TrimSpace(originalRef) != "" { - if p, n, err := parseRecognitionUnitRef(originalRef); err == nil { - originalTemplate = p - originalName = n - } - } - if _, err := s.GetProfileEditor(sceneTemplate); err != nil { - return err - } - if sceneTemplate != originalTemplate { - if _, err := s.GetProfileEditor(originalTemplate); err != nil { - return err - } - } - if sceneTemplate != originalTemplate || unitName != originalName { - existing, err := s.assets.GetRecognitionUnit(sceneTemplate, unitName) - if err != nil { - return err - } - if existing != nil { - return fmt.Errorf("duplicate recognition unit name: %s", unitName) - } - } - if err := s.assets.SaveRecognitionUnit(recognitionUnitToRecord(unit)); err != nil { - return err - } - if sceneTemplate != originalTemplate || unitName != originalName { - if err := s.assets.DeleteRecognitionUnit(originalTemplate, originalName); err != nil { - return err - } - } - return nil + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + sceneTemplate := strings.TrimSpace(unit.SceneTemplateName) + if err := validateConfigName(sceneTemplate); err != nil { + return fmt.Errorf("invalid scene template: %w", err) + } + unitName := strings.TrimSpace(unit.Name) + if err := validateConfigName(unitName); err != nil { + return fmt.Errorf("invalid recognition unit name: %w", err) + } + if strings.TrimSpace(unit.VideoSourceRef) == "" { + return fmt.Errorf("video source is required") + } + originalTemplate := sceneTemplate + originalName := unitName + if strings.TrimSpace(originalRef) != "" { + if p, n, err := parseRecognitionUnitRef(originalRef); err == nil { + originalTemplate = p + originalName = n + } + } + if _, err := s.GetProfileEditor(sceneTemplate); err != nil { + return err + } + if sceneTemplate != originalTemplate { + if _, err := s.GetProfileEditor(originalTemplate); err != nil { + return err + } + } + if sceneTemplate != originalTemplate || unitName != originalName { + existing, err := s.assets.GetRecognitionUnit(sceneTemplate, unitName) + if err != nil { + return err + } + if existing != nil { + return fmt.Errorf("duplicate recognition unit name: %s", unitName) + } + } + if err := s.assets.SaveRecognitionUnit(recognitionUnitToRecord(unit)); err != nil { + return err + } + if sceneTemplate != originalTemplate || unitName != originalName { + if err := s.assets.DeleteRecognitionUnit(originalTemplate, originalName); err != nil { + return err + } + } + return nil } func (s *ConfigPreviewService) loadRecognitionUnitEditors(sceneTemplateName string, templateName string) ([]ConfigProfileInstanceEditor, string, string, error) { - units, err := s.ListRecognitionUnits() - if err != nil { - return nil, "", "", err - } - instances := make([]ConfigProfileInstanceEditor, 0) - siteName := "" - deviceCode := "" - for _, unit := range units { - if unit.SceneTemplateName != sceneTemplateName { - continue - } - instances = append(instances, recognitionUnitToInstanceEditor(unit, templateName)) - if siteName == "" && strings.TrimSpace(unit.SiteName) != "" { - siteName = strings.TrimSpace(unit.SiteName) - } - if deviceCode == "" { - if code := strings.TrimSpace(stringAny(unit.AdvancedParams["device_code"])); code != "" { - deviceCode = code - } - } - } - return instances, siteName, deviceCode, nil + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, "", "", err + } + instances := make([]ConfigProfileInstanceEditor, 0) + siteName := "" + deviceCode := "" + for _, unit := range units { + if unit.SceneTemplateName != sceneTemplateName { + continue + } + instances = append(instances, recognitionUnitToInstanceEditor(unit, templateName)) + if siteName == "" && strings.TrimSpace(unit.SiteName) != "" { + siteName = strings.TrimSpace(unit.SiteName) + } + if deviceCode == "" { + if code := strings.TrimSpace(stringAny(unit.AdvancedParams["device_code"])); code != "" { + deviceCode = code + } + } + } + return instances, siteName, deviceCode, nil } func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName string) ConfigProfileInstanceEditor { - channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name)) - rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555")) - return ConfigProfileInstanceEditor{ - Name: strings.TrimSpace(unit.Name), - Template: templateName, - VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef), - DisplayName: strings.TrimSpace(unit.DisplayName), - SiteName: strings.TrimSpace(unit.SiteName), - ChannelNo: channel, - PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", - PublishRTSPPort: rtspPort, - PublishRTSPPath: "/live/" + channel, - InputBindings: map[string]InputBindingEditor{ - "video_input_main": {VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef)}, - }, - OutputBindings: map[string]OutputBindingEditor{ - "stream_output_main": { - PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", - PublishRTSPPort: rtspPort, - PublishRTSPPath: "/live/" + channel, - ChannelNo: channel, - }, - }, - AdvancedParams: cloneMap(unit.AdvancedParams), - } + channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name)) + rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555")) + return ConfigProfileInstanceEditor{ + Name: strings.TrimSpace(unit.Name), + Template: templateName, + VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef), + DisplayName: strings.TrimSpace(unit.DisplayName), + SiteName: strings.TrimSpace(unit.SiteName), + ChannelNo: channel, + PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", + PublishRTSPPort: rtspPort, + PublishRTSPPath: "/live/" + channel, + InputBindings: map[string]InputBindingEditor{ + "video_input_main": {VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef)}, + }, + OutputBindings: map[string]OutputBindingEditor{ + "stream_output_main": { + PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8", + PublishRTSPPort: rtspPort, + PublishRTSPPath: "/live/" + channel, + ChannelNo: channel, + }, + }, + AdvancedParams: cloneMap(unit.AdvancedParams), + } } func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitRecord { - channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name)) - if channel == "" { - channel = strings.TrimSpace(unit.Name) - } - rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555")) - raw := map[string]any{ - "name": strings.TrimSpace(unit.Name), - "scene_meta": map[string]any{ - "display_name": strings.TrimSpace(unit.DisplayName), - "site_name": strings.TrimSpace(unit.SiteName), - }, - "input_bindings": map[string]any{ - "video_input_main": map[string]any{ - "video_source_ref": strings.TrimSpace(unit.VideoSourceRef), - }, - }, - "output_bindings": map[string]any{ - "stream_output_main": map[string]any{ - "publish_hls_path": "./web/hls/" + unit.Name + "/index.m3u8", - "publish_rtsp_port": rtspPort, - "publish_rtsp_path": "/live/" + channel, - "channel_no": channel, - }, - }, - } - if params := cloneMap(unit.AdvancedParams); len(params) > 0 { - raw["params"] = params - } - body, _ := marshalConfigJSON(raw) - return storage.RecognitionUnitRecord{ - SceneTemplateName: strings.TrimSpace(unit.SceneTemplateName), - Name: strings.TrimSpace(unit.Name), - DisplayName: strings.TrimSpace(unit.DisplayName), - SiteName: strings.TrimSpace(unit.SiteName), - VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef), - OutputChannel: channel, - RTSPPort: rtspPort, - Description: strings.TrimSpace(unit.Description), - BodyJSON: string(body), - } + channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name)) + if channel == "" { + channel = strings.TrimSpace(unit.Name) + } + rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555")) + raw := map[string]any{ + "name": strings.TrimSpace(unit.Name), + "scene_meta": map[string]any{ + "display_name": strings.TrimSpace(unit.DisplayName), + "site_name": strings.TrimSpace(unit.SiteName), + }, + "input_bindings": map[string]any{ + "video_input_main": map[string]any{ + "video_source_ref": strings.TrimSpace(unit.VideoSourceRef), + }, + }, + "output_bindings": map[string]any{ + "stream_output_main": map[string]any{ + "publish_hls_path": "./web/hls/" + unit.Name + "/index.m3u8", + "publish_rtsp_port": rtspPort, + "publish_rtsp_path": "/live/" + channel, + "channel_no": channel, + }, + }, + } + if params := cloneMap(unit.AdvancedParams); len(params) > 0 { + raw["params"] = params + } + body, _ := marshalConfigJSON(raw) + return storage.RecognitionUnitRecord{ + SceneTemplateName: strings.TrimSpace(unit.SceneTemplateName), + Name: strings.TrimSpace(unit.Name), + DisplayName: strings.TrimSpace(unit.DisplayName), + SiteName: strings.TrimSpace(unit.SiteName), + VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef), + OutputChannel: channel, + RTSPPort: rtspPort, + Description: strings.TrimSpace(unit.Description), + BodyJSON: string(body), + } } func recognitionUnitFromRecord(record storage.RecognitionUnitRecord) (*RecognitionUnitAsset, error) { - raw := map[string]any{} - if strings.TrimSpace(record.BodyJSON) != "" { - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - return nil, err - } - } - sceneMeta, _ := raw["scene_meta"].(map[string]any) - params, _ := raw["params"].(map[string]any) - advanced := cloneMap(params) - if deviceCode := strings.TrimSpace(stringAny(sceneMeta["device_code"])); deviceCode != "" { - advanced["device_code"] = deviceCode - } - return &RecognitionUnitAsset{ - Ref: recognitionUnitRef(record.SceneTemplateName, record.Name), - SceneTemplateName: strings.TrimSpace(record.SceneTemplateName), - Name: strings.TrimSpace(firstString(stringAny(raw["name"]), record.Name)), - DisplayName: strings.TrimSpace(firstString(stringAny(sceneMeta["display_name"]), record.DisplayName)), - SiteName: strings.TrimSpace(firstString(stringAny(sceneMeta["site_name"]), record.SiteName)), - VideoSourceRef: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "input_bindings", "video_input_main", "video_source_ref"), record.VideoSourceRef)), - OutputChannel: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "channel_no"), record.OutputChannel)), - RTSPPort: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "publish_rtsp_port"), record.RTSPPort)), - Description: strings.TrimSpace(record.Description), - AdvancedParams: advanced, - }, nil + raw := map[string]any{} + if strings.TrimSpace(record.BodyJSON) != "" { + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + } + sceneMeta, _ := raw["scene_meta"].(map[string]any) + params, _ := raw["params"].(map[string]any) + advanced := cloneMap(params) + if deviceCode := strings.TrimSpace(stringAny(sceneMeta["device_code"])); deviceCode != "" { + advanced["device_code"] = deviceCode + } + return &RecognitionUnitAsset{ + Ref: recognitionUnitRef(record.SceneTemplateName, record.Name), + SceneTemplateName: strings.TrimSpace(record.SceneTemplateName), + Name: strings.TrimSpace(firstString(stringAny(raw["name"]), record.Name)), + DisplayName: strings.TrimSpace(firstString(stringAny(sceneMeta["display_name"]), record.DisplayName)), + SiteName: strings.TrimSpace(firstString(stringAny(sceneMeta["site_name"]), record.SiteName)), + VideoSourceRef: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "input_bindings", "video_input_main", "video_source_ref"), record.VideoSourceRef)), + OutputChannel: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "channel_no"), record.OutputChannel)), + RTSPPort: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "publish_rtsp_port"), record.RTSPPort)), + Description: strings.TrimSpace(record.Description), + AdvancedParams: advanced, + }, nil } func bindingStringFromAnyMap(raw map[string]any, bindingKey string, slot string, field string) string { - bindings, _ := raw[bindingKey].(map[string]any) - if bindings == nil { - return "" - } - return bindingStringFromBindings(bindings, slot, field) + bindings, _ := raw[bindingKey].(map[string]any) + if bindings == nil { + return "" + } + return bindingStringFromBindings(bindings, slot, field) } func bindingStringFromBindings(bindings map[string]any, slot string, field string) string { - entry, _ := bindings[slot].(map[string]any) - return strings.TrimSpace(stringAny(entry[field])) + entry, _ := bindings[slot].(map[string]any) + return strings.TrimSpace(stringAny(entry[field])) } func (e *ConfigProfileEditor) RawTemplateName() string { - if e == nil { - return "" - } - if strings.TrimSpace(e.PrimaryTemplateName) != "" { - return strings.TrimSpace(e.PrimaryTemplateName) - } - return firstProfileTemplate(e.Instances) + if e == nil { + return "" + } + if strings.TrimSpace(e.PrimaryTemplateName) != "" { + return strings.TrimSpace(e.PrimaryTemplateName) + } + return firstProfileTemplate(e.Instances) } func (s *ConfigPreviewService) DeleteRecognitionUnit(ref string) error { - sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref) - if err != nil { - return err - } - if refs, err := s.deviceAssignmentsReferencingRecognitionUnit(ref); err != nil { - return err - } else if len(refs) > 0 { - return fmt.Errorf("识别单元 %q 已被设备分配引用:%s", unitName, strings.Join(refs, ", ")) - } - item, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName) - if err != nil { - return err - } - if item == nil { - return os.ErrNotExist - } - return s.assets.DeleteRecognitionUnit(sceneTemplateName, unitName) + sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref) + if err != nil { + return err + } + if refs, err := s.deviceAssignmentsReferencingRecognitionUnit(ref); err != nil { + return err + } else if len(refs) > 0 { + return fmt.Errorf("识别单元 %q 已被设备分配引用:%s", unitName, strings.Join(refs, ", ")) + } + item, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName) + if err != nil { + return err + } + if item == nil { + return os.ErrNotExist + } + return s.assets.DeleteRecognitionUnit(sceneTemplateName, unitName) } func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) { - sources, err := s.ListSources() - if err != nil { - return nil, err - } - items := make([]ConfigOverlayAsset, 0, len(sources.Overlays)) - for _, source := range sources.Overlays { - item, err := s.GetOverlayAsset(source.Name) - if err != nil { - continue - } - items = append(items, *item) - } - return items, nil + sources, err := s.ListSources() + if err != nil { + return nil, err + } + items := make([]ConfigOverlayAsset, 0, len(sources.Overlays)) + for _, source := range sources.Overlays { + item, err := s.GetOverlayAsset(source.Name) + if err != nil { + continue + } + items = append(items, *item) + } + return items, nil } func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset, error) { - raw, path, err := s.readAssetJSON("overlays", name) - if err != nil { - return nil, err - } - targets := make([]string, 0) - if overrides, ok := raw["instance_overrides"].(map[string]any); ok { - for key := range overrides { - targets = append(targets, key) - } - sort.Strings(targets) - } - return &ConfigOverlayAsset{ - Name: name, - Path: path, - Description: stringValue(raw["description"]), - OverrideTargets: targets, - OverrideTargetNum: len(targets), - Raw: raw, - }, nil + raw, path, err := s.readAssetJSON("overlays", name) + if err != nil { + return nil, err + } + targets := make([]string, 0) + if overrides, ok := raw["instance_overrides"].(map[string]any); ok { + for key := range overrides { + targets = append(targets, key) + } + sort.Strings(targets) + } + return &ConfigOverlayAsset{ + Name: name, + Path: path, + Description: stringValue(raw["description"]), + OverrideTargets: targets, + OverrideTargetNum: len(targets), + Raw: raw, + }, nil } func (s *ConfigPreviewService) SaveOverlayAsset(asset ConfigOverlayAsset, raw map[string]any) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - name := strings.TrimSpace(asset.Name) - if err := validateConfigName(name); err != nil { - return err - } - if raw == nil { - raw = map[string]any{} - } - raw["name"] = name - raw["description"] = strings.TrimSpace(asset.Description) - body, err := marshalConfigJSON(raw) - if err != nil { - return err - } - return s.assets.SaveOverlay(name, strings.TrimSpace(asset.Description), string(body)) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + name := strings.TrimSpace(asset.Name) + if err := validateConfigName(name); err != nil { + return err + } + if raw == nil { + raw = map[string]any{} + } + raw["name"] = name + raw["description"] = strings.TrimSpace(asset.Description) + body, err := marshalConfigJSON(raw) + if err != nil { + return err + } + return s.assets.SaveOverlay(name, strings.TrimSpace(asset.Description), string(body)) } func (s *ConfigPreviewService) DeleteOverlayAsset(name string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - if err := validateConfigName(name); err != nil { - return err - } - refs, err := s.profileNamesReferencingOverlay(name) - if err != nil { - return err - } - if len(refs) > 0 { - return fmt.Errorf("调试参数 %q 已被场景模板引用:%s", name, strings.Join(refs, ", ")) - } - return s.assets.DeleteOverlay(name) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return err + } + refs, err := s.profileNamesReferencingOverlay(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("调试参数 %q 已被场景模板引用:%s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteOverlay(name) } func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) { - if s == nil || s.assets == nil { - return nil, nil - } - records, err := s.assets.ListIntegrationServices() - if err != nil { - return nil, err - } - items := make([]ConfigIntegrationServiceAsset, 0, len(records)) - for _, record := range records { - item, err := integrationServiceAssetFromRecord(record) - if err != nil { - continue - } - if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { - item.RefCount = len(refs) - } - items = append(items, *item) - } - sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) - return items, nil + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListIntegrationServices() + if err != nil { + return nil, err + } + items := make([]ConfigIntegrationServiceAsset, 0, len(records)) + for _, record := range records { + item, err := integrationServiceAssetFromRecord(record) + if err != nil { + continue + } + if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { + item.RefCount = len(refs) + } + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + return items, nil } func (s *ConfigPreviewService) ListVideoSources() ([]ConfigVideoSourceAsset, error) { - if s == nil || s.assets == nil { - return nil, nil - } - records, err := s.assets.ListVideoSources() - if err != nil { - return nil, err - } - items := make([]ConfigVideoSourceAsset, 0, len(records)) - for _, record := range records { - item, err := videoSourceAssetFromRecord(record) - if err != nil { - continue - } - if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil { - item.RefCount = len(refs) - } - items = append(items, *item) - } - sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) - return items, nil + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListVideoSources() + if err != nil { + return nil, err + } + items := make([]ConfigVideoSourceAsset, 0, len(records)) + for _, record := range records { + item, err := videoSourceAssetFromRecord(record) + if err != nil { + continue + } + if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil { + item.RefCount = len(refs) + } + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + return items, nil } func (s *ConfigPreviewService) GetVideoSource(name string) (*ConfigVideoSourceAsset, error) { - if s == nil || s.assets == nil { - return nil, fmt.Errorf("基础配置仓库未初始化") - } - if err := validateVideoSourceName(name); err != nil { - return nil, err - } - record, err := s.assets.GetVideoSource(name) - if err != nil { - return nil, err - } - if record == nil { - return nil, os.ErrNotExist - } - item, err := videoSourceAssetFromRecord(*record) - if err != nil { - return nil, err - } - if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil { - item.RefCount = len(refs) - } - return item, nil + if s == nil || s.assets == nil { + return nil, fmt.Errorf("基础配置仓库未初始化") + } + if err := validateVideoSourceName(name); err != nil { + return nil, err + } + record, err := s.assets.GetVideoSource(name) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + item, err := videoSourceAssetFromRecord(*record) + if err != nil { + return nil, err + } + if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil { + item.RefCount = len(refs) + } + return item, nil } func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset) error { - if s == nil || s.assets == nil { - return fmt.Errorf("基础配置仓库未初始化") - } - name := strings.TrimSpace(asset.Name) - if err := validateVideoSourceName(name); err != nil { - return fmt.Errorf("视频源名称不合法:%w", err) - } - sourceType := strings.TrimSpace(asset.SourceType) - if sourceType == "" { - return fmt.Errorf("视频源类型不能为空") - } - urlValue := strings.TrimSpace(asset.Config.URL) - if urlValue == "" { - return fmt.Errorf("视频源地址不能为空") - } - raw := map[string]any{ - "name": name, - "source_type": sourceType, - "area": strings.TrimSpace(asset.Area), - "description": strings.TrimSpace(asset.Description), - "config": map[string]any{}, - } - configMap := raw["config"].(map[string]any) - setAnyString(configMap, "url", asset.Config.URL) - setAnyString(configMap, "resolution", asset.Config.Resolution) - setAnyString(configMap, "frame_size", asset.Config.FrameSize) - setAnyString(configMap, "fps", asset.Config.FPS) - setAnyString(configMap, "video_format", asset.Config.VideoFormat) - setAnyString(configMap, "focal_length", asset.Config.FocalLength) - setAnyString(configMap, "mount_height", asset.Config.MountHeight) - setAnyString(configMap, "mount_angle", asset.Config.MountAngle) - body, err := marshalConfigJSON(raw) - if err != nil { - return err - } - return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body)) + if s == nil || s.assets == nil { + return fmt.Errorf("基础配置仓库未初始化") + } + name := strings.TrimSpace(asset.Name) + if err := validateVideoSourceName(name); err != nil { + return fmt.Errorf("视频源名称不合法:%w", err) + } + sourceType := strings.TrimSpace(asset.SourceType) + if sourceType == "" { + return fmt.Errorf("视频源类型不能为空") + } + urlValue := strings.TrimSpace(asset.Config.URL) + if urlValue == "" { + return fmt.Errorf("视频源地址不能为空") + } + raw := map[string]any{ + "name": name, + "source_type": sourceType, + "area": strings.TrimSpace(asset.Area), + "description": strings.TrimSpace(asset.Description), + "config": map[string]any{}, + } + configMap := raw["config"].(map[string]any) + setAnyString(configMap, "url", asset.Config.URL) + setAnyString(configMap, "resolution", asset.Config.Resolution) + setAnyString(configMap, "frame_size", asset.Config.FrameSize) + setAnyString(configMap, "fps", asset.Config.FPS) + setAnyString(configMap, "video_format", asset.Config.VideoFormat) + setAnyString(configMap, "focal_length", asset.Config.FocalLength) + setAnyString(configMap, "mount_height", asset.Config.MountHeight) + setAnyString(configMap, "mount_angle", asset.Config.MountAngle) + body, err := marshalConfigJSON(raw) + if err != nil { + return err + } + return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body)) } func (s *ConfigPreviewService) DeleteVideoSource(name string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("基础配置仓库未初始化") - } - if err := validateVideoSourceName(name); err != nil { - return err - } - refs, err := s.profileNamesReferencingVideoSource(name) - if err != nil { - return err - } - if len(refs) > 0 { - return fmt.Errorf("视频源 %q 已被场景模板引用:%s", name, strings.Join(refs, ", ")) - } - return s.assets.DeleteVideoSource(name) + if s == nil || s.assets == nil { + return fmt.Errorf("基础配置仓库未初始化") + } + if err := validateVideoSourceName(name); err != nil { + return err + } + refs, err := s.profileNamesReferencingVideoSource(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("视频源 %q 已被场景模板引用:%s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteVideoSource(name) } func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) { - if s == nil || s.assets == nil { - return nil, fmt.Errorf("asset repository is not configured") - } - if err := validateConfigName(name); err != nil { - return nil, err - } - record, err := s.assets.GetIntegrationService(name) - if err != nil { - return nil, err - } - if record == nil { - return nil, os.ErrNotExist - } - item, err := integrationServiceAssetFromRecord(*record) - if err != nil { - return nil, err - } - if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { - item.RefCount = len(refs) - } - return item, nil + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return nil, err + } + record, err := s.assets.GetIntegrationService(name) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + item, err := integrationServiceAssetFromRecord(*record) + if err != nil { + return nil, err + } + if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { + item.RefCount = len(refs) + } + return item, nil } func (s *ConfigPreviewService) DeleteIntegrationService(name string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - if err := validateConfigName(name); err != nil { - return err - } - refs, err := s.profileNamesReferencingIntegrationService(name) - if err != nil { - return err - } - if len(refs) > 0 { - return fmt.Errorf("third-party service %q is used by scene configs: %s", name, strings.Join(refs, ", ")) - } - return s.assets.DeleteIntegrationService(name) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return err + } + refs, err := s.profileNamesReferencingIntegrationService(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("third-party service %q is used by scene configs: %s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteIntegrationService(name) } func (s *ConfigPreviewService) ListDeviceAssignments() ([]DeviceAssignmentAsset, error) { - if s == nil || s.assets == nil { - return nil, nil - } - records, err := s.assets.ListDeviceAssignments() - if err != nil { - return nil, err - } - items := make([]DeviceAssignmentAsset, 0, len(records)) - for _, record := range records { - item, err := deviceAssignmentFromRecord(record) - if err != nil { - continue - } - items = append(items, *item) - } - sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID }) - return items, nil + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListDeviceAssignments() + if err != nil { + return nil, err + } + items := make([]DeviceAssignmentAsset, 0, len(records)) + for _, record := range records { + item, err := deviceAssignmentFromRecord(record) + if err != nil { + continue + } + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID }) + return items, nil } func DefaultMaxUnitsPerDevice() int { - return 4 + return 4 } func (s *ConfigPreviewService) BuildDeviceAssignmentBoard(devices []*models.Device, maxUnitsPerDevice int) (*DeviceAssignmentBoard, error) { - assignments, err := s.ListDeviceAssignments() - if err != nil { - return nil, err - } - units, err := s.ListRecognitionUnits() - if err != nil { - return nil, err - } - return BuildDeviceAssignmentBoardData(devices, assignments, units, maxUnitsPerDevice), nil + assignments, err := s.ListDeviceAssignments() + if err != nil { + return nil, err + } + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, err + } + return BuildDeviceAssignmentBoardData(devices, assignments, units, maxUnitsPerDevice), nil } func BuildAutoDeviceAssignments(devices []*models.Device, units []RecognitionUnitAsset, maxUnitsPerDevice int) []DeviceAssignmentAsset { - maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice) - type card struct { - deviceID string - deviceName string - profile string - refs []string - } - cards := make([]*card, 0, len(devices)) - for _, dev := range devices { - if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { - continue - } - cards = append(cards, &card{deviceID: strings.TrimSpace(dev.DeviceID), deviceName: dev.DisplayName()}) - } - sort.Slice(cards, func(i, j int) bool { - return cards[i].deviceID < cards[j].deviceID - }) - sortedUnits := append([]RecognitionUnitAsset(nil), units...) - sort.Slice(sortedUnits, func(i, j int) bool { - if sortedUnits[i].SceneTemplateName != sortedUnits[j].SceneTemplateName { - return sortedUnits[i].SceneTemplateName < sortedUnits[j].SceneTemplateName - } - return sortedUnits[i].Name < sortedUnits[j].Name - }) - for _, unit := range sortedUnits { - var target *card - for _, candidate := range cards { - if candidate.profile != "" && candidate.profile != unit.SceneTemplateName { - continue - } - if len(candidate.refs) >= maxUnitsPerDevice { - continue - } - if target == nil || len(candidate.refs) < len(target.refs) { - target = candidate - } - } - if target == nil { - continue - } - if target.profile == "" { - target.profile = unit.SceneTemplateName - } - target.refs = append(target.refs, unit.Ref) - } - out := make([]DeviceAssignmentAsset, 0, len(cards)) - for _, card := range cards { - if len(card.refs) == 0 { - continue - } - out = append(out, DeviceAssignmentAsset{ - DeviceID: card.deviceID, - ProfileName: card.profile, - RecognitionUnits: append([]string(nil), card.refs...), - RecognitionCount: len(card.refs), - }) - } - sort.Slice(out, func(i, j int) bool { return out[i].DeviceID < out[j].DeviceID }) - return out + maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice) + type card struct { + deviceID string + deviceName string + profile string + refs []string + } + cards := make([]*card, 0, len(devices)) + for _, dev := range devices { + if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { + continue + } + cards = append(cards, &card{deviceID: strings.TrimSpace(dev.DeviceID), deviceName: dev.DisplayName()}) + } + sort.Slice(cards, func(i, j int) bool { + return cards[i].deviceID < cards[j].deviceID + }) + sortedUnits := append([]RecognitionUnitAsset(nil), units...) + sort.Slice(sortedUnits, func(i, j int) bool { + if sortedUnits[i].SceneTemplateName != sortedUnits[j].SceneTemplateName { + return sortedUnits[i].SceneTemplateName < sortedUnits[j].SceneTemplateName + } + return sortedUnits[i].Name < sortedUnits[j].Name + }) + for _, unit := range sortedUnits { + var target *card + for _, candidate := range cards { + if candidate.profile != "" && candidate.profile != unit.SceneTemplateName { + continue + } + if len(candidate.refs) >= maxUnitsPerDevice { + continue + } + if target == nil || len(candidate.refs) < len(target.refs) { + target = candidate + } + } + if target == nil { + continue + } + if target.profile == "" { + target.profile = unit.SceneTemplateName + } + target.refs = append(target.refs, unit.Ref) + } + out := make([]DeviceAssignmentAsset, 0, len(cards)) + for _, card := range cards { + if len(card.refs) == 0 { + continue + } + out = append(out, DeviceAssignmentAsset{ + DeviceID: card.deviceID, + ProfileName: card.profile, + RecognitionUnits: append([]string(nil), card.refs...), + RecognitionCount: len(card.refs), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].DeviceID < out[j].DeviceID }) + return out } func BuildDeviceAssignmentBoardData(devices []*models.Device, assignments []DeviceAssignmentAsset, units []RecognitionUnitAsset, maxUnitsPerDevice int) *DeviceAssignmentBoard { - maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice) - unitMap := make(map[string]RecognitionUnitAsset, len(units)) - for _, unit := range units { - unitMap[unit.Ref] = unit - } - deviceByID := map[string]*models.Device{} - for _, dev := range devices { - if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { - continue - } - deviceByID[strings.TrimSpace(dev.DeviceID)] = dev - } - assignmentByDevice := map[string]DeviceAssignmentAsset{} - for _, assignment := range assignments { - assignmentByDevice[strings.TrimSpace(assignment.DeviceID)] = assignment - } - deviceIDs := make([]string, 0, len(deviceByID)+len(assignmentByDevice)) - seenDeviceIDs := map[string]struct{}{} - for deviceID := range deviceByID { - seenDeviceIDs[deviceID] = struct{}{} - deviceIDs = append(deviceIDs, deviceID) - } - for deviceID := range assignmentByDevice { - if _, ok := seenDeviceIDs[deviceID]; ok { - continue - } - seenDeviceIDs[deviceID] = struct{}{} - deviceIDs = append(deviceIDs, deviceID) - } - sort.Strings(deviceIDs) + maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice) + unitMap := make(map[string]RecognitionUnitAsset, len(units)) + for _, unit := range units { + unitMap[unit.Ref] = unit + } + deviceByID := map[string]*models.Device{} + for _, dev := range devices { + if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { + continue + } + deviceByID[strings.TrimSpace(dev.DeviceID)] = dev + } + assignmentByDevice := map[string]DeviceAssignmentAsset{} + for _, assignment := range assignments { + assignmentByDevice[strings.TrimSpace(assignment.DeviceID)] = assignment + } + deviceIDs := make([]string, 0, len(deviceByID)+len(assignmentByDevice)) + seenDeviceIDs := map[string]struct{}{} + for deviceID := range deviceByID { + seenDeviceIDs[deviceID] = struct{}{} + deviceIDs = append(deviceIDs, deviceID) + } + for deviceID := range assignmentByDevice { + if _, ok := seenDeviceIDs[deviceID]; ok { + continue + } + seenDeviceIDs[deviceID] = struct{}{} + deviceIDs = append(deviceIDs, deviceID) + } + sort.Strings(deviceIDs) - assignedRefs := map[string]struct{}{} - cards := make([]DeviceAssignmentBoardCard, 0, len(deviceIDs)) - for _, deviceID := range deviceIDs { - assignment := assignmentByDevice[deviceID] - card := DeviceAssignmentBoardCard{ - DeviceID: deviceID, - DeviceName: boardDeviceName(deviceByID[deviceID], deviceID), - ProfileName: strings.TrimSpace(assignment.ProfileName), - MaxUnits: maxUnitsPerDevice, - } - for _, ref := range assignment.RecognitionUnits { - unit, ok := unitMap[ref] - if !ok { - continue - } - card.Units = append(card.Units, boardUnitFromRecognitionUnit(unit)) - assignedRefs[ref] = struct{}{} - } - card.AssignedCount = len(card.Units) - card.Status = boardCardStatus(card.AssignedCount, maxUnitsPerDevice) - cards = append(cards, card) - } - sort.Slice(cards, func(i, j int) bool { - li := boardStatusRank(cards[i].Status) - lj := boardStatusRank(cards[j].Status) - if li != lj { - return li < lj - } - if cards[i].AssignedCount != cards[j].AssignedCount { - return cards[i].AssignedCount > cards[j].AssignedCount - } - return cards[i].DeviceID < cards[j].DeviceID - }) + assignedRefs := map[string]struct{}{} + cards := make([]DeviceAssignmentBoardCard, 0, len(deviceIDs)) + for _, deviceID := range deviceIDs { + assignment := assignmentByDevice[deviceID] + card := DeviceAssignmentBoardCard{ + DeviceID: deviceID, + DeviceName: boardDeviceName(deviceByID[deviceID], deviceID), + ProfileName: strings.TrimSpace(assignment.ProfileName), + MaxUnits: maxUnitsPerDevice, + } + for _, ref := range assignment.RecognitionUnits { + unit, ok := unitMap[ref] + if !ok { + continue + } + card.Units = append(card.Units, boardUnitFromRecognitionUnit(unit)) + assignedRefs[ref] = struct{}{} + } + card.AssignedCount = len(card.Units) + card.Status = boardCardStatus(card.AssignedCount, maxUnitsPerDevice) + cards = append(cards, card) + } + sort.Slice(cards, func(i, j int) bool { + li := boardStatusRank(cards[i].Status) + lj := boardStatusRank(cards[j].Status) + if li != lj { + return li < lj + } + if cards[i].AssignedCount != cards[j].AssignedCount { + return cards[i].AssignedCount > cards[j].AssignedCount + } + return cards[i].DeviceID < cards[j].DeviceID + }) - unassigned := make([]DeviceAssignmentBoardUnit, 0) - for _, unit := range units { - if _, ok := assignedRefs[unit.Ref]; ok { - continue - } - unassigned = append(unassigned, boardUnitFromRecognitionUnit(unit)) - } - sort.Slice(unassigned, func(i, j int) bool { - if unassigned[i].SceneTemplateName != unassigned[j].SceneTemplateName { - return unassigned[i].SceneTemplateName < unassigned[j].SceneTemplateName - } - return unassigned[i].Name < unassigned[j].Name - }) + unassigned := make([]DeviceAssignmentBoardUnit, 0) + for _, unit := range units { + if _, ok := assignedRefs[unit.Ref]; ok { + continue + } + unassigned = append(unassigned, boardUnitFromRecognitionUnit(unit)) + } + sort.Slice(unassigned, func(i, j int) bool { + if unassigned[i].SceneTemplateName != unassigned[j].SceneTemplateName { + return unassigned[i].SceneTemplateName < unassigned[j].SceneTemplateName + } + return unassigned[i].Name < unassigned[j].Name + }) - stats := DeviceAssignmentBoardStats{ - TotalUnits: len(units), - TotalDevices: len(deviceIDs), - AssignedUnits: len(assignedRefs), - UnassignedUnits: len(unassigned), - } - if stats.TotalDevices > 0 { - stats.AverageLoad = float64(stats.AssignedUnits) / float64(stats.TotalDevices) - } - for _, card := range cards { - if card.Status == "full" { - stats.OverloadedDevices++ - } - } - return &DeviceAssignmentBoard{ - MaxUnitsPerDevice: maxUnitsPerDevice, - Stats: stats, - Cards: cards, - Unassigned: unassigned, - } + stats := DeviceAssignmentBoardStats{ + TotalUnits: len(units), + TotalDevices: len(deviceIDs), + AssignedUnits: len(assignedRefs), + UnassignedUnits: len(unassigned), + } + if stats.TotalDevices > 0 { + stats.AverageLoad = float64(stats.AssignedUnits) / float64(stats.TotalDevices) + } + for _, card := range cards { + if card.Status == "full" { + stats.OverloadedDevices++ + } + } + return &DeviceAssignmentBoard{ + MaxUnitsPerDevice: maxUnitsPerDevice, + Stats: stats, + Cards: cards, + Unassigned: unassigned, + } } func deviceAssignmentFromRecord(record storage.DeviceAssignmentRecord) (*DeviceAssignmentAsset, error) { - raw := map[string]any{} - if strings.TrimSpace(record.BodyJSON) != "" { - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - return nil, err - } - } - unitRefs := make([]string, 0) - if items, ok := raw["recognition_units"].([]any); ok { - for _, item := range items { - if v := strings.TrimSpace(stringValue(item)); v != "" { - unitRefs = append(unitRefs, v) - } - } - } - return &DeviceAssignmentAsset{ - DeviceID: record.DeviceID, - ProfileName: firstString(record.ProfileName, stringValue(raw["profile_name"])), - Description: firstString(record.Description, stringValue(raw["description"])), - RecognitionUnits: unitRefs, - RecognitionCount: len(unitRefs), - }, nil + raw := map[string]any{} + if strings.TrimSpace(record.BodyJSON) != "" { + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + } + unitRefs := make([]string, 0) + if items, ok := raw["recognition_units"].([]any); ok { + for _, item := range items { + if v := strings.TrimSpace(stringValue(item)); v != "" { + unitRefs = append(unitRefs, v) + } + } + } + return &DeviceAssignmentAsset{ + DeviceID: record.DeviceID, + ProfileName: firstString(record.ProfileName, stringValue(raw["profile_name"])), + Description: firstString(record.Description, stringValue(raw["description"])), + RecognitionUnits: unitRefs, + RecognitionCount: len(unitRefs), + }, nil } func normalizeMaxUnitsPerDevice(v int) int { - if v < 1 { - return DefaultMaxUnitsPerDevice() - } - if v > 8 { - return 8 - } - return v + if v < 1 { + return DefaultMaxUnitsPerDevice() + } + if v > 8 { + return 8 + } + return v } func boardDeviceName(dev *models.Device, fallback string) string { - if dev == nil { - return fallback - } - return firstString(strings.TrimSpace(dev.DisplayName()), fallback) + if dev == nil { + return fallback + } + return firstString(strings.TrimSpace(dev.DisplayName()), fallback) } func boardUnitFromRecognitionUnit(unit RecognitionUnitAsset) DeviceAssignmentBoardUnit { - return DeviceAssignmentBoardUnit{ - Ref: unit.Ref, - SceneTemplateName: unit.SceneTemplateName, - Name: unit.Name, - DisplayName: unit.DisplayName, - VideoSourceRef: unit.VideoSourceRef, - OutputChannel: firstString(unit.OutputChannel, unit.Name), - } + return DeviceAssignmentBoardUnit{ + Ref: unit.Ref, + SceneTemplateName: unit.SceneTemplateName, + Name: unit.Name, + DisplayName: unit.DisplayName, + VideoSourceRef: unit.VideoSourceRef, + OutputChannel: firstString(unit.OutputChannel, unit.Name), + } } func boardCardStatus(count int, max int) string { - if count <= 0 { - return "idle" - } - if count >= max { - return "full" - } - if count*2 >= max { - return "busy" - } - return "low" + if count <= 0 { + return "idle" + } + if count >= max { + return "full" + } + if count*2 >= max { + return "busy" + } + return "low" } func boardStatusRank(status string) int { - switch status { - case "full": - return 0 - case "busy": - return 1 - case "low": - return 2 - case "idle": - return 3 - default: - return 4 - } + switch status { + case "full": + return 0 + case "busy": + return 1 + case "low": + return 2 + case "idle": + return 3 + default: + return 4 + } } func (s *ConfigPreviewService) GetDeviceAssignment(deviceID string) (*DeviceAssignmentAsset, error) { - if s == nil || s.assets == nil { - return nil, fmt.Errorf("asset repository is not configured") - } - record, err := s.assets.GetDeviceAssignment(strings.TrimSpace(deviceID)) - if err != nil { - return nil, err - } - if record == nil { - return nil, os.ErrNotExist - } - return deviceAssignmentFromRecord(*record) + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + record, err := s.assets.GetDeviceAssignment(strings.TrimSpace(deviceID)) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + return deviceAssignmentFromRecord(*record) } func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - deviceID := strings.TrimSpace(asset.DeviceID) - if deviceID == "" { - return fmt.Errorf("device id is required") - } - if strings.TrimSpace(asset.ProfileName) == "" { - return fmt.Errorf("scene template is required") - } - seen := map[string]struct{}{} - for _, ref := range asset.RecognitionUnits { - profileName, _, err := parseRecognitionUnitRef(ref) - if err != nil { - return err - } - if profileName != asset.ProfileName { - return fmt.Errorf("all recognition units must belong to scene template %q", asset.ProfileName) - } - if _, ok := seen[ref]; ok { - return fmt.Errorf("duplicate recognition unit: %s", ref) - } - seen[ref] = struct{}{} - } - raw := map[string]any{ - "device_id": deviceID, - "profile_name": strings.TrimSpace(asset.ProfileName), - "description": strings.TrimSpace(asset.Description), - "recognition_units": asset.RecognitionUnits, - } - body, err := marshalConfigJSON(raw) - if err != nil { - return err - } - return s.assets.SaveDeviceAssignment(deviceID, strings.TrimSpace(asset.ProfileName), strings.TrimSpace(asset.Description), string(body)) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + deviceID := strings.TrimSpace(asset.DeviceID) + if deviceID == "" { + return fmt.Errorf("device id is required") + } + if strings.TrimSpace(asset.ProfileName) == "" { + return fmt.Errorf("scene template is required") + } + seen := map[string]struct{}{} + for _, ref := range asset.RecognitionUnits { + profileName, _, err := parseRecognitionUnitRef(ref) + if err != nil { + return err + } + if profileName != asset.ProfileName { + return fmt.Errorf("all recognition units must belong to scene template %q", asset.ProfileName) + } + if _, ok := seen[ref]; ok { + return fmt.Errorf("duplicate recognition unit: %s", ref) + } + seen[ref] = struct{}{} + } + raw := map[string]any{ + "device_id": deviceID, + "profile_name": strings.TrimSpace(asset.ProfileName), + "description": strings.TrimSpace(asset.Description), + "recognition_units": asset.RecognitionUnits, + } + body, err := marshalConfigJSON(raw) + if err != nil { + return err + } + return s.assets.SaveDeviceAssignment(deviceID, strings.TrimSpace(asset.ProfileName), strings.TrimSpace(asset.Description), string(body)) } func (s *ConfigPreviewService) SaveDeviceAssignmentBoard(assignments map[string][]string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - existing, err := s.ListDeviceAssignments() - if err != nil { - return err - } - existingByDevice := make(map[string]DeviceAssignmentAsset, len(existing)) - for _, item := range existing { - existingByDevice[item.DeviceID] = item - } - for deviceID, refs := range assignments { - deviceID = strings.TrimSpace(deviceID) - if deviceID == "" { - continue - } - cleanRefs := make([]string, 0, len(refs)) - seen := map[string]struct{}{} - for _, ref := range refs { - ref = strings.TrimSpace(ref) - if ref == "" { - continue - } - if _, ok := seen[ref]; ok { - continue - } - seen[ref] = struct{}{} - cleanRefs = append(cleanRefs, ref) - } - if len(cleanRefs) == 0 { - if _, ok := existingByDevice[deviceID]; ok { - if err := s.assets.DeleteDeviceAssignment(deviceID); err != nil { - return err - } - } - continue - } - firstUnit, err := s.GetRecognitionUnit(cleanRefs[0]) - if err != nil { - return err - } - profileName := firstUnit.SceneTemplateName - for _, ref := range cleanRefs[1:] { - unit, err := s.GetRecognitionUnit(ref) - if err != nil { - return err - } - if unit.SceneTemplateName != profileName { - return fmt.Errorf("device %q contains recognition units from different scene templates", deviceID) - } - } - description := existingByDevice[deviceID].Description - if err := s.SaveDeviceAssignment(DeviceAssignmentAsset{ - DeviceID: deviceID, - ProfileName: profileName, - Description: description, - RecognitionUnits: cleanRefs, - }); err != nil { - return err - } - } - return nil + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + existing, err := s.ListDeviceAssignments() + if err != nil { + return err + } + existingByDevice := make(map[string]DeviceAssignmentAsset, len(existing)) + for _, item := range existing { + existingByDevice[item.DeviceID] = item + } + for deviceID, refs := range assignments { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + continue + } + cleanRefs := make([]string, 0, len(refs)) + seen := map[string]struct{}{} + for _, ref := range refs { + ref = strings.TrimSpace(ref) + if ref == "" { + continue + } + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + cleanRefs = append(cleanRefs, ref) + } + if len(cleanRefs) == 0 { + if _, ok := existingByDevice[deviceID]; ok { + if err := s.assets.DeleteDeviceAssignment(deviceID); err != nil { + return err + } + } + continue + } + firstUnit, err := s.GetRecognitionUnit(cleanRefs[0]) + if err != nil { + return err + } + profileName := firstUnit.SceneTemplateName + for _, ref := range cleanRefs[1:] { + unit, err := s.GetRecognitionUnit(ref) + if err != nil { + return err + } + if unit.SceneTemplateName != profileName { + return fmt.Errorf("device %q contains recognition units from different scene templates", deviceID) + } + } + description := existingByDevice[deviceID].Description + if err := s.SaveDeviceAssignment(DeviceAssignmentAsset{ + DeviceID: deviceID, + ProfileName: profileName, + Description: description, + RecognitionUnits: cleanRefs, + }); err != nil { + return err + } + } + return nil } func (s *ConfigPreviewService) DeleteDeviceAssignment(deviceID string) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - return s.assets.DeleteDeviceAssignment(strings.TrimSpace(deviceID)) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + return s.assets.DeleteDeviceAssignment(strings.TrimSpace(deviceID)) } func (s *ConfigPreviewService) deviceAssignmentsReferencingRecognitionUnit(ref string) ([]string, error) { - items, err := s.ListDeviceAssignments() - if err != nil { - return nil, err - } - out := make([]string, 0) - for _, item := range items { - for _, unitRef := range item.RecognitionUnits { - if unitRef == ref { - out = append(out, item.DeviceID) - break - } - } - } - sort.Strings(out) - return out, nil + items, err := s.ListDeviceAssignments() + if err != nil { + return nil, err + } + out := make([]string, 0) + for _, item := range items { + for _, unitRef := range item.RecognitionUnits { + if unitRef == ref { + out = append(out, item.DeviceID) + break + } + } + } + sort.Strings(out) + return out, nil } func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error { - if s == nil || s.assets == nil { - return fmt.Errorf("asset repository is not configured") - } - name := strings.TrimSpace(asset.Name) - if err := validateConfigName(name); err != nil { - return fmt.Errorf("invalid third-party service name: %w", err) - } - serviceType := strings.TrimSpace(asset.Type) - if serviceType == "" { - return fmt.Errorf("third-party service type is required") - } - raw := map[string]any{ - "name": name, - "type": serviceType, - "description": strings.TrimSpace(asset.Description), - "enabled": asset.Enabled, - } - configMap := map[string]any{} - switch serviceType { - case "object_storage": - if asset.ObjectStorage == nil { - return fmt.Errorf("object storage config is required") - } - setAnyString(configMap, "endpoint", asset.ObjectStorage.Endpoint) - setAnyString(configMap, "bucket", asset.ObjectStorage.Bucket) - setAnyString(configMap, "access_key", asset.ObjectStorage.AccessKey) - setAnyString(configMap, "secret_key", asset.ObjectStorage.SecretKey) - for _, key := range []string{"endpoint", "bucket", "access_key", "secret_key"} { - if strings.TrimSpace(stringValue(configMap[key])) == "" { - return fmt.Errorf("object storage %s is required", key) - } - } - case "token_service": - if asset.TokenService == nil { - return fmt.Errorf("token service config is required") - } - setAnyString(configMap, "get_token_url", asset.TokenService.GetTokenURL) - setAnyString(configMap, "username", asset.TokenService.Username) - setAnyString(configMap, "password", asset.TokenService.Password) - setAnyString(configMap, "tenant_code", asset.TokenService.TenantCode) - if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" { - return fmt.Errorf("token service get_token_url is required") - } - case "alarm_service": - if asset.AlarmService == nil { - return fmt.Errorf("alarm service config is required") - } - setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL) - setAnyString(configMap, "username", asset.AlarmService.Username) - setAnyString(configMap, "password", asset.AlarmService.Password) - setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode) - if strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" { - return fmt.Errorf("alarm service put_message_url is required") - } - default: - return fmt.Errorf("unsupported third-party service type: %s", serviceType) - } - raw["config"] = configMap - body, err := marshalConfigJSON(raw) - if err != nil { - return err - } - return s.assets.SaveIntegrationService(name, serviceType, strings.TrimSpace(asset.Description), asset.Enabled, string(body)) + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + name := strings.TrimSpace(asset.Name) + if err := validateConfigName(name); err != nil { + return fmt.Errorf("invalid third-party service name: %w", err) + } + serviceType := strings.TrimSpace(asset.Type) + if serviceType == "" { + return fmt.Errorf("third-party service type is required") + } + raw := map[string]any{ + "name": name, + "type": serviceType, + "description": strings.TrimSpace(asset.Description), + "enabled": asset.Enabled, + } + configMap := map[string]any{} + switch serviceType { + case "object_storage": + if asset.ObjectStorage == nil { + return fmt.Errorf("object storage config is required") + } + setAnyString(configMap, "endpoint", asset.ObjectStorage.Endpoint) + setAnyString(configMap, "bucket", asset.ObjectStorage.Bucket) + setAnyString(configMap, "access_key", asset.ObjectStorage.AccessKey) + setAnyString(configMap, "secret_key", asset.ObjectStorage.SecretKey) + for _, key := range []string{"endpoint", "bucket", "access_key", "secret_key"} { + if strings.TrimSpace(stringValue(configMap[key])) == "" { + return fmt.Errorf("object storage %s is required", key) + } + } + case "token_service": + if asset.TokenService == nil { + return fmt.Errorf("token service config is required") + } + setAnyString(configMap, "get_token_url", asset.TokenService.GetTokenURL) + setAnyString(configMap, "username", asset.TokenService.Username) + setAnyString(configMap, "password", asset.TokenService.Password) + setAnyString(configMap, "tenant_code", asset.TokenService.TenantCode) + if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" { + return fmt.Errorf("token service get_token_url is required") + } + case "alarm_service": + if asset.AlarmService == nil { + return fmt.Errorf("alarm service config is required") + } + setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL) + setAnyString(configMap, "username", asset.AlarmService.Username) + setAnyString(configMap, "password", asset.AlarmService.Password) + setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode) + if strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" { + return fmt.Errorf("alarm service put_message_url is required") + } + default: + return fmt.Errorf("unsupported third-party service type: %s", serviceType) + } + raw["config"] = configMap + body, err := marshalConfigJSON(raw) + if err != nil { + return err + } + return s.assets.SaveIntegrationService(name, serviceType, strings.TrimSpace(asset.Description), asset.Enabled, string(body)) } func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name string) ([]string, error) { - if s == nil || s.assets == nil { - return nil, nil - } - units, err := s.ListRecognitionUnits() - if err != nil { - return nil, err - } - name = strings.TrimSpace(name) - if name == "" { - return nil, nil - } - seen := map[string]struct{}{} - refs := make([]string, 0) - for _, unit := range units { - record, err := s.assets.GetRecognitionUnit(unit.SceneTemplateName, unit.Name) - if err != nil || record == nil { - continue - } - raw := map[string]any{} - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - continue - } - for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} { - if strings.TrimSpace(bindingStringFromAnyMap(raw, "service_bindings", slot, "service_ref")) == name { - if _, ok := seen[unit.SceneTemplateName]; !ok { - seen[unit.SceneTemplateName] = struct{}{} - refs = append(refs, unit.SceneTemplateName) - } - break - } - } - } - sort.Strings(refs) - return refs, nil + if s == nil || s.assets == nil { + return nil, nil + } + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, err + } + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + seen := map[string]struct{}{} + refs := make([]string, 0) + for _, unit := range units { + record, err := s.assets.GetRecognitionUnit(unit.SceneTemplateName, unit.Name) + if err != nil || record == nil { + continue + } + raw := map[string]any{} + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + continue + } + for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} { + if strings.TrimSpace(bindingStringFromAnyMap(raw, "service_bindings", slot, "service_ref")) == name { + if _, ok := seen[unit.SceneTemplateName]; !ok { + seen[unit.SceneTemplateName] = struct{}{} + refs = append(refs, unit.SceneTemplateName) + } + break + } + } + } + sort.Strings(refs) + return refs, nil } func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) { - if s == nil || s.assets == nil { - return nil, nil - } - units, err := s.ListRecognitionUnits() - if err != nil { - return nil, err - } - name = strings.TrimSpace(name) - if name == "" { - return nil, nil - } - seen := map[string]struct{}{} - refs := make([]string, 0) - for _, unit := range units { - if strings.TrimSpace(unit.VideoSourceRef) == name { - if _, ok := seen[unit.SceneTemplateName]; !ok { - seen[unit.SceneTemplateName] = struct{}{} - refs = append(refs, unit.SceneTemplateName) - } - } - } - sort.Strings(refs) - return refs, nil + if s == nil || s.assets == nil { + return nil, nil + } + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, err + } + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + seen := map[string]struct{}{} + refs := make([]string, 0) + for _, unit := range units { + if strings.TrimSpace(unit.VideoSourceRef) == name { + if _, ok := seen[unit.SceneTemplateName]; !ok { + seen[unit.SceneTemplateName] = struct{}{} + refs = append(refs, unit.SceneTemplateName) + } + } + } + sort.Strings(refs) + return refs, nil } func (s *ConfigPreviewService) profileNamesReferencingOverlay(name string) ([]string, error) { - if s == nil || s.assets == nil { - return nil, nil - } - records, err := s.assets.ListProfiles() - if err != nil { - return nil, err - } - name = strings.TrimSpace(name) - if name == "" { - return nil, nil - } - refs := make([]string, 0) - for _, record := range records { - var raw map[string]any - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - continue - } - for _, overlay := range profileOverlayNames(raw) { - if strings.TrimSpace(overlay) == name { - refs = append(refs, record.Name) - break - } - } - } - sort.Strings(refs) - return refs, nil + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListProfiles() + if err != nil { + return nil, err + } + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + refs := make([]string, 0) + for _, record := range records { + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + continue + } + for _, overlay := range profileOverlayNames(raw) { + if strings.TrimSpace(overlay) == name { + refs = append(refs, record.Name) + break + } + } + } + sort.Strings(refs) + return refs, nil } func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) { - if s == nil || s.assets == nil { - return nil, "", fmt.Errorf("asset repository is not configured") - } - raw, path, ok, err := s.readRepoAssetJSON(kind, name) - if err != nil { - return nil, "", err - } - if ok { - return raw, path, nil - } - return nil, "", os.ErrNotExist + if s == nil || s.assets == nil { + return nil, "", fmt.Errorf("asset repository is not configured") + } + raw, path, ok, err := s.readRepoAssetJSON(kind, name) + if err != nil { + return nil, "", err + } + if ok { + return raw, path, nil + } + return nil, "", os.ErrNotExist } func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) { - if err := validateConfigName(name); err != nil { - return nil, "", false, err - } - var ( - record *storage.AssetRecord - err error - ) - switch kind { - case "templates": - record, err = s.assets.GetTemplate(name) - case "profiles": - record, err = s.assets.GetProfile(name) - case "overlays": - record, err = s.assets.GetOverlay(name) - default: - return nil, "", false, fmt.Errorf("unsupported asset kind: %s", kind) - } - if err != nil { - return nil, "", true, err - } - if record == nil { - return nil, "", false, nil - } - var raw map[string]any - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - return nil, "", true, err - } - if raw == nil { - raw = map[string]any{} - } - if strings.TrimSpace(record.Description) != "" { - raw["description"] = record.Description - } - if kind == "profiles" { - if strings.TrimSpace(record.TemplateName) != "" { - raw["primary_template_name"] = record.TemplateName - } - if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" { - raw["business_name"] = record.BusinessName - } - } - return raw, repoAssetPath(kind, name), true, nil + if err := validateConfigName(name); err != nil { + return nil, "", false, err + } + var ( + record *storage.AssetRecord + err error + ) + switch kind { + case "templates": + record, err = s.assets.GetTemplate(name) + case "profiles": + record, err = s.assets.GetProfile(name) + case "overlays": + record, err = s.assets.GetOverlay(name) + default: + return nil, "", false, fmt.Errorf("unsupported asset kind: %s", kind) + } + if err != nil { + return nil, "", true, err + } + if record == nil { + return nil, "", false, nil + } + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, "", true, err + } + if raw == nil { + raw = map[string]any{} + } + if strings.TrimSpace(record.Description) != "" { + raw["description"] = record.Description + } + if kind == "profiles" { + if strings.TrimSpace(record.TemplateName) != "" { + raw["primary_template_name"] = record.TemplateName + } + if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" { + raw["business_name"] = record.BusinessName + } + } + return raw, repoAssetPath(kind, name), true, nil } func cloneMap(in map[string]any) map[string]any { - if len(in) == 0 { - return map[string]any{} - } - out := make(map[string]any, len(in)) - for k, v := range in { - out[k] = v - } - return out + if len(in) == 0 { + return map[string]any{} + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out } func bindingAny(bindings map[string]any, slot string, field string) any { - entry, _ := bindings[slot].(map[string]any) - return entry[field] + entry, _ := bindings[slot].(map[string]any) + return entry[field] } func (s *ConfigPreviewService) templateAssetFromRecord(record storage.AssetRecord, origin string, readOnly bool) (*ConfigTemplateAsset, error) { - var raw map[string]any - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - return nil, err - } - if raw == nil { - raw = map[string]any{} - } - if strings.TrimSpace(record.Description) != "" { - raw["description"] = record.Description - } - return buildTemplateAsset(raw, repoAssetPath("templates", record.Name), origin, readOnly), nil + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + if raw == nil { + raw = map[string]any{} + } + if strings.TrimSpace(record.Description) != "" { + raw["description"] = record.Description + } + return buildTemplateAsset(raw, repoAssetPath("templates", record.Name), origin, readOnly), nil } func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly bool) *ConfigTemplateAsset { - templateMap, _ := raw["template"].(map[string]any) - paramsMap, _ := raw["params"].(map[string]any) - nodes, _ := templateMap["nodes"].([]any) - edges, _ := templateMap["edges"].([]any) - slots, _ := parseTemplateSlots(raw) - advanced := cloneMap(paramsMap) - for _, key := range []string{ - "minio_endpoint", - "minio_bucket", - "external_get_token_url", - "external_put_message_url", - "tenant_code", - } { - delete(advanced, key) - } - if len(advanced) == 0 { - advanced = nil - } - return &ConfigTemplateAsset{ - Name: firstString(raw["name"], filepath.Base(strings.TrimSuffix(path, filepath.Ext(path)))), - Path: path, - Origin: origin, - ReadOnly: readOnly, - Description: stringValue(raw["description"]), - Source: stringValue(raw["source"]), - Slots: slots, - NodeCount: len(nodes), - EdgeCount: len(edges), - MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]), - MinIOBucket: stringValue(paramsMap["minio_bucket"]), - ExternalGetTokenURL: stringValue(paramsMap["external_get_token_url"]), - ExternalPutMessageURL: stringValue(paramsMap["external_put_message_url"]), - TenantCode: valueString(paramsMap["tenant_code"]), - AdvancedParams: advanced, - Raw: raw, - } + templateMap, _ := raw["template"].(map[string]any) + paramsMap, _ := raw["params"].(map[string]any) + nodes, _ := templateMap["nodes"].([]any) + edges, _ := templateMap["edges"].([]any) + slots, _ := parseTemplateSlots(raw) + advanced := cloneMap(paramsMap) + for _, key := range []string{ + "minio_endpoint", + "minio_bucket", + "external_get_token_url", + "external_put_message_url", + "tenant_code", + } { + delete(advanced, key) + } + if len(advanced) == 0 { + advanced = nil + } + return &ConfigTemplateAsset{ + Name: firstString(raw["name"], filepath.Base(strings.TrimSuffix(path, filepath.Ext(path)))), + Path: path, + Origin: origin, + ReadOnly: readOnly, + Description: stringValue(raw["description"]), + Source: stringValue(raw["source"]), + Slots: slots, + NodeCount: len(nodes), + EdgeCount: len(edges), + MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]), + MinIOBucket: stringValue(paramsMap["minio_bucket"]), + ExternalGetTokenURL: stringValue(paramsMap["external_get_token_url"]), + ExternalPutMessageURL: stringValue(paramsMap["external_put_message_url"]), + TenantCode: valueString(paramsMap["tenant_code"]), + AdvancedParams: advanced, + Raw: raw, + } } func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) (*ConfigIntegrationServiceAsset, error) { - var raw map[string]any - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - return nil, err - } - if raw == nil { - raw = map[string]any{} - } - configMap, _ := raw["config"].(map[string]any) - sourceMap := raw - if len(configMap) > 0 { - sourceMap = configMap - } - item := &ConfigIntegrationServiceAsset{ - Name: firstString(raw["name"], record.Name), - Path: repoAssetPath("integration_services", record.Name), - Type: firstString(record.ServiceType, stringValue(raw["type"])), - Description: firstString(raw["description"], record.Description), - Enabled: boolValue(raw["enabled"], record.Enabled), - Raw: raw, - } - item.TypeLabel = integrationTypeLabel(item.Type) - switch item.Type { - case "object_storage": - item.ObjectStorage = &ObjectStorageConfig{ - Endpoint: stringValue(sourceMap["endpoint"]), - Bucket: stringValue(sourceMap["bucket"]), - AccessKey: firstString(sourceMap["access_key"], stringValue(sourceMap["minio_access_key"])), - SecretKey: firstString(sourceMap["secret_key"], stringValue(sourceMap["minio_secret_key"])), - } - item.AddressSummary = strings.TrimSpace(strings.Trim(strings.Join([]string{item.ObjectStorage.Endpoint, item.ObjectStorage.Bucket}, " / "), " /")) - case "token_service": - item.TokenService = &TokenServiceConfig{ - GetTokenURL: stringValue(sourceMap["get_token_url"]), - Username: stringValue(sourceMap["username"]), - Password: stringValue(sourceMap["password"]), - TenantCode: stringValue(sourceMap["tenant_code"]), - } - item.AddressSummary = item.TokenService.GetTokenURL - case "alarm_service": - item.AlarmService = &AlarmServiceConfig{ - PutMessageURL: stringValue(sourceMap["put_message_url"]), - Username: stringValue(sourceMap["username"]), - Password: stringValue(sourceMap["password"]), - TenantCode: stringValue(sourceMap["tenant_code"]), - } - item.AddressSummary = item.AlarmService.PutMessageURL - } - return item, nil + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + if raw == nil { + raw = map[string]any{} + } + configMap, _ := raw["config"].(map[string]any) + sourceMap := raw + if len(configMap) > 0 { + sourceMap = configMap + } + item := &ConfigIntegrationServiceAsset{ + Name: firstString(raw["name"], record.Name), + Path: repoAssetPath("integration_services", record.Name), + Type: firstString(record.ServiceType, stringValue(raw["type"])), + Description: firstString(raw["description"], record.Description), + Enabled: boolValue(raw["enabled"], record.Enabled), + Raw: raw, + } + item.TypeLabel = integrationTypeLabel(item.Type) + switch item.Type { + case "object_storage": + item.ObjectStorage = &ObjectStorageConfig{ + Endpoint: stringValue(sourceMap["endpoint"]), + Bucket: stringValue(sourceMap["bucket"]), + AccessKey: firstString(sourceMap["access_key"], stringValue(sourceMap["minio_access_key"])), + SecretKey: firstString(sourceMap["secret_key"], stringValue(sourceMap["minio_secret_key"])), + } + item.AddressSummary = strings.TrimSpace(strings.Trim(strings.Join([]string{item.ObjectStorage.Endpoint, item.ObjectStorage.Bucket}, " / "), " /")) + case "token_service": + item.TokenService = &TokenServiceConfig{ + GetTokenURL: stringValue(sourceMap["get_token_url"]), + Username: stringValue(sourceMap["username"]), + Password: stringValue(sourceMap["password"]), + TenantCode: stringValue(sourceMap["tenant_code"]), + } + item.AddressSummary = item.TokenService.GetTokenURL + case "alarm_service": + item.AlarmService = &AlarmServiceConfig{ + PutMessageURL: stringValue(sourceMap["put_message_url"]), + Username: stringValue(sourceMap["username"]), + Password: stringValue(sourceMap["password"]), + TenantCode: stringValue(sourceMap["tenant_code"]), + } + item.AddressSummary = item.AlarmService.PutMessageURL + } + return item, nil } func videoSourceAssetFromRecord(record storage.VideoSourceRecord) (*ConfigVideoSourceAsset, error) { - var raw map[string]any - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - return nil, err - } - if raw == nil { - raw = map[string]any{} - } - configMap, _ := raw["config"].(map[string]any) - sourceMap := raw - if len(configMap) > 0 { - sourceMap = configMap - } - item := &ConfigVideoSourceAsset{ - Name: firstString(raw["name"], record.Name), - Path: repoAssetPath("video_sources", record.Name), - SourceType: firstString(record.SourceType, stringValue(raw["source_type"])), - Area: firstString(raw["area"], record.Area), - Description: firstString(raw["description"], record.Description), - SourceTypeLabel: videoSourceTypeLabel(firstString(record.SourceType, stringValue(raw["source_type"]))), - Config: VideoSourceConfig{ - URL: stringValue(sourceMap["url"]), - Resolution: stringValue(sourceMap["resolution"]), - FrameSize: stringValue(sourceMap["frame_size"]), - FPS: valueString(sourceMap["fps"]), - VideoFormat: stringValue(sourceMap["video_format"]), - FocalLength: stringValue(sourceMap["focal_length"]), - MountHeight: stringValue(sourceMap["mount_height"]), - MountAngle: stringValue(sourceMap["mount_angle"]), - }, - Raw: raw, - } - item.SourceSummary = item.Config.URL - return item, nil + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + if raw == nil { + raw = map[string]any{} + } + configMap, _ := raw["config"].(map[string]any) + sourceMap := raw + if len(configMap) > 0 { + sourceMap = configMap + } + item := &ConfigVideoSourceAsset{ + Name: firstString(raw["name"], record.Name), + Path: repoAssetPath("video_sources", record.Name), + SourceType: firstString(record.SourceType, stringValue(raw["source_type"])), + Area: firstString(raw["area"], record.Area), + Description: firstString(raw["description"], record.Description), + SourceTypeLabel: videoSourceTypeLabel(firstString(record.SourceType, stringValue(raw["source_type"]))), + Config: VideoSourceConfig{ + URL: stringValue(sourceMap["url"]), + Resolution: stringValue(sourceMap["resolution"]), + FrameSize: stringValue(sourceMap["frame_size"]), + FPS: valueString(sourceMap["fps"]), + VideoFormat: stringValue(sourceMap["video_format"]), + FocalLength: stringValue(sourceMap["focal_length"]), + MountHeight: stringValue(sourceMap["mount_height"]), + MountAngle: stringValue(sourceMap["mount_angle"]), + }, + Raw: raw, + } + item.SourceSummary = item.Config.URL + return item, nil } func integrationTypeLabel(v string) string { - switch strings.TrimSpace(v) { - case "object_storage": - return "对象存储" - case "token_service": - return "认证服务" - case "alarm_service": - return "告警服务" - default: - return strings.TrimSpace(v) - } + switch strings.TrimSpace(v) { + case "object_storage": + return "对象存储" + case "token_service": + return "认证服务" + case "alarm_service": + return "告警服务" + default: + return strings.TrimSpace(v) + } } func videoSourceTypeLabel(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) - } + 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 validateVideoSourceName(name string) error { - name = strings.TrimSpace(name) - if name == "" { - return fmt.Errorf("不能为空") - } - if strings.Contains(name, "..") { - return fmt.Errorf("不能包含 '..'") - } - if strings.ContainsAny(name, `/\`) { - return fmt.Errorf("不能包含 / 或 \\") - } - return nil + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("不能为空") + } + if strings.Contains(name, "..") { + return fmt.Errorf("不能包含 '..'") + } + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("不能包含 / 或 \\") + } + return nil } func (s *ConfigPreviewService) templateIsBuiltin(name string) bool { - return isStandardTemplateName(name) + return isStandardTemplateName(name) } func isStandardTemplateName(name string) bool { - name = canonicalTemplateAssetName(name) - return strings.HasPrefix(strings.TrimSpace(name), "std_") + name = canonicalTemplateAssetName(name) + return strings.HasPrefix(strings.TrimSpace(name), "std_") } func canonicalTemplateAssetName(name string) string { - name = strings.TrimSpace(name) - if next := strings.TrimSpace(legacyBuiltinTemplateAliases[name]); next != "" { - return next - } - return name + name = strings.TrimSpace(name) + if next := strings.TrimSpace(legacyBuiltinTemplateAliases[name]); next != "" { + return next + } + return name } func stringValue(v any) string { - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - return "" + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + return "" } func valueString(v any) string { - switch value := v.(type) { - case string: - return strings.TrimSpace(value) - case float64: - if float64(int(value)) == value { - return fmt.Sprintf("%d", int(value)) - } - return fmt.Sprintf("%v", value) - case int: - return fmt.Sprintf("%d", value) - case int64: - return fmt.Sprintf("%d", value) - default: - return "" - } + switch value := v.(type) { + case string: + return strings.TrimSpace(value) + case float64: + if float64(int(value)) == value { + return fmt.Sprintf("%d", int(value)) + } + return fmt.Sprintf("%v", value) + case int: + return fmt.Sprintf("%d", value) + case int64: + return fmt.Sprintf("%d", value) + default: + return "" + } } func boolValue(v any, fallback bool) bool { - switch value := v.(type) { - case bool: - return value - case float64: - return value != 0 - case int: - return value != 0 - case int64: - return value != 0 - case string: - value = strings.TrimSpace(strings.ToLower(value)) - return value == "1" || value == "true" || value == "yes" || value == "on" - default: - return fallback - } + switch value := v.(type) { + case bool: + return value + case float64: + return value != 0 + case int: + return value != 0 + case int64: + return value != 0 + case string: + value = strings.TrimSpace(strings.ToLower(value)) + return value == "1" || value == "true" || value == "yes" || value == "on" + default: + return fallback + } } func firstString(v any, fallback string) string { - if got := stringValue(v); got != "" { - return got - } - return fallback + if got := stringValue(v); got != "" { + return got + } + return fallback } func intValue(v any) int { - switch value := v.(type) { - case int: - return value - case int64: - return int(value) - case float64: - return int(value) - default: - return 0 - } + switch value := v.(type) { + case int: + return value + case int64: + return int(value) + case float64: + return int(value) + default: + return 0 + } } diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index d7621b8..b69cdbc 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -1,43 +1,43 @@ package service import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" ) func mustSaveTemplateRecord(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) { - t.Helper() - if err := repo.SaveTemplate(name, description, body); err != nil { - t.Fatalf("SaveTemplate(%s): %v", name, err) - } + t.Helper() + if err := repo.SaveTemplate(name, description, body); err != nil { + t.Fatalf("SaveTemplate(%s): %v", name, err) + } } func mustReadFileBytes(t *testing.T, path string) []byte { - t.Helper() - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile(%s): %v", path, err) - } - return body + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + return body } func mustImportPreviewAssets(t *testing.T, svc *ConfigPreviewService) { - t.Helper() - if _, err := svc.ImportAssetsFromMediaRepo(); err != nil { - t.Fatalf("ImportAssetsFromMediaRepo: %v", err) - } + t.Helper() + if _, err := svc.ImportAssetsFromMediaRepo(); err != nil { + t.Fatalf("ImportAssetsFromMediaRepo: %v", err) + } } func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { - root := t.TempDir() - templateBody := `{ + root := t.TempDir() + templateBody := `{ "name": "std_workshop_face_recognition_shoe_alarm", "source": "standard", "params": { @@ -50,7 +50,7 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { }, "template": {"nodes": [], "edges": []} }` - mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name": "local_3588_test", "business_name": "A厂区视觉识别", "description": "test profile", @@ -64,55 +64,55 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { "output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}} }] }`) - mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplateRecord(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", templateBody) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplateRecord(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", templateBody) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveOverlay("face_debug", "", `{}`); err != nil { - t.Fatalf("SaveOverlay: %v", err) - } - item, err := svc.GetProfileAsset("local_3588_test") - if err != nil { - t.Fatalf("GetProfileAsset: %v", err) - } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveOverlay("face_debug", "", `{}`); err != nil { + t.Fatalf("SaveOverlay: %v", err) + } + item, err := svc.GetProfileAsset("local_3588_test") + if err != nil { + t.Fatalf("GetProfileAsset: %v", err) + } - if item.Name != "local_3588_test" || len(item.Instances) != 1 { - t.Fatalf("unexpected profile summary: %#v", item) - } - inst := item.Instances[0] - if inst.DisplayName != "东门入口" { - t.Fatalf("expected display name to be parsed, got %#v", inst) - } - if inst.PublishRTSPPort != "8555" { - t.Fatalf("expected rtsp port to be stringified, got %#v", inst.PublishRTSPPort) - } - if _, ok := inst.AdvancedParams["queue_debug"]; !ok { - t.Fatalf("expected advanced params to preserve extra keys, got %#v", inst.AdvancedParams) - } - if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil { - t.Fatalf("GetTemplateAsset: %v", err) - } else { - if _, ok := item.AdvancedParams["snapshot_region"]; !ok { - t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams) - } - } + if item.Name != "local_3588_test" || len(item.Instances) != 1 { + t.Fatalf("unexpected profile summary: %#v", item) + } + inst := item.Instances[0] + if inst.DisplayName != "东门入口" { + t.Fatalf("expected display name to be parsed, got %#v", inst) + } + if inst.PublishRTSPPort != "8555" { + t.Fatalf("expected rtsp port to be stringified, got %#v", inst.PublishRTSPPort) + } + if _, ok := inst.AdvancedParams["queue_debug"]; !ok { + t.Fatalf("expected advanced params to preserve extra keys, got %#v", inst.AdvancedParams) + } + if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil { + t.Fatalf("GetTemplateAsset: %v", err) + } else { + if _, ok := item.AdvancedParams["snapshot_region"]; !ok { + t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams) + } + } } func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) - mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) - mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{ + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{ "description": "启用人脸识别和陌生候选调试日志,用于联调和测试。", "instance_overrides": { "*": {"override": {}}, @@ -120,33 +120,33 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) { } }`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportPreviewAssets(t, svc) - item, err := svc.GetOverlayAsset("face_debug") - if err != nil { - t.Fatalf("GetOverlayAsset: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportPreviewAssets(t, svc) + item, err := svc.GetOverlayAsset("face_debug") + if err != nil { + t.Fatalf("GetOverlayAsset: %v", err) + } - if item.OverrideTargetNum != 2 { - t.Fatalf("expected 2 override targets, got %#v", item) - } - if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" { - t.Fatalf("unexpected targets: %#v", item.OverrideTargets) - } - if item.Description != "启用人脸识别和陌生候选调试日志,用于联调和测试。" { - t.Fatalf("expected localized overlay description, got %#v", item.Description) - } + if item.OverrideTargetNum != 2 { + t.Fatalf("expected 2 override targets, got %#v", item) + } + if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" { + t.Fatalf("unexpected targets: %#v", item.OverrideTargets) + } + if item.Description != "启用人脸识别和陌生候选调试日志,用于联调和测试。" { + t.Fatalf("expected localized overlay description, got %#v", item.Description) + } } func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name": "local_3588_test", "business_name": "A厂区视觉识别", "description": "test profile", @@ -170,942 +170,942 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { ] }`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - editor, err := svc.GetProfileEditor("local_3588_test") - if err != nil { - t.Fatalf("GetProfileEditor: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + editor, err := svc.GetProfileEditor("local_3588_test") + if err != nil { + t.Fatalf("GetProfileEditor: %v", err) + } - if editor.Name != "local_3588_test" { - t.Fatalf("unexpected profile name: %#v", editor) - } - if editor.BusinessName != "A厂区视觉识别" { - t.Fatalf("expected business name to be parsed, got %#v", editor.BusinessName) - } - if editor.SiteName != "A厂区" { - t.Fatalf("expected profile site name to be parsed, got %#v", editor.SiteName) - } - if editor.DeviceCode != "rk3588-a-001" { - t.Fatalf("expected legacy device code to be preserved internally, got %#v", editor.DeviceCode) - } - if len(editor.Instances) != 2 { - t.Fatalf("expected two instances, got %#v", editor.Instances) - } - if editor.Instances[0].Name != "cam1" || editor.Instances[0].DisplayName != "东门入口" { - t.Fatalf("unexpected first instance summary: %#v", editor.Instances[0]) - } - if editor.Instances[1].Name != "cam2" || editor.Instances[1].VideoSourceRef != "line_cam_02" { - t.Fatalf("unexpected second instance summary: %#v", editor.Instances[1]) - } - if editor.Instances[0].PublishRTSPPort != "8555" { - t.Fatalf("expected rtsp port to be stringified, got %#v", editor.Instances[0].PublishRTSPPort) - } - if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" { - t.Fatalf("unexpected queue model: %#v", editor.Queue) - } - if editor.Instances[0].VideoSourceRef != "gate_cam_01" { - t.Fatalf("expected slot-driven video source ref, got %#v", editor.Instances[0]) - } - if _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok { - t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams) - } + if editor.Name != "local_3588_test" { + t.Fatalf("unexpected profile name: %#v", editor) + } + if editor.BusinessName != "A厂区视觉识别" { + t.Fatalf("expected business name to be parsed, got %#v", editor.BusinessName) + } + if editor.SiteName != "A厂区" { + t.Fatalf("expected profile site name to be parsed, got %#v", editor.SiteName) + } + if editor.DeviceCode != "rk3588-a-001" { + t.Fatalf("expected legacy device code to be preserved internally, got %#v", editor.DeviceCode) + } + if len(editor.Instances) != 2 { + t.Fatalf("expected two instances, got %#v", editor.Instances) + } + if editor.Instances[0].Name != "cam1" || editor.Instances[0].DisplayName != "东门入口" { + t.Fatalf("unexpected first instance summary: %#v", editor.Instances[0]) + } + if editor.Instances[1].Name != "cam2" || editor.Instances[1].VideoSourceRef != "line_cam_02" { + t.Fatalf("unexpected second instance summary: %#v", editor.Instances[1]) + } + if editor.Instances[0].PublishRTSPPort != "8555" { + t.Fatalf("expected rtsp port to be stringified, got %#v", editor.Instances[0].PublishRTSPPort) + } + if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" { + t.Fatalf("unexpected queue model: %#v", editor.Queue) + } + if editor.Instances[0].VideoSourceRef != "gate_cam_01" { + t.Fatalf("expected slot-driven video source ref, got %#v", editor.Instances[0]) + } + if _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok { + t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams) + } } func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { - svc := NewConfigPreviewService(&config.Config{}) - editor := ConfigProfileEditor{ - Name: "local_3588_test", - BusinessName: "A厂区视觉识别", - Description: "test profile", - OverlayName: "face_debug", - DeviceCode: "rk3588-a-001", - SiteName: "A厂区", - Instances: []ConfigProfileInstanceEditor{ - { - Name: "cam1", - Template: "std_workshop_face_recognition_shoe_alarm", - VideoSourceRef: "gate_cam_01", - DisplayName: "东门入口", - PublishHLSPath: "./web/hls/cam1/index.m3u8", - PublishRTSPPort: "8555", - PublishRTSPPath: "/live/cam1", - ChannelNo: "cam1", - AdvancedParams: map[string]any{ - "queue_debug": true, - }, - }, - { - Name: "cam2", - Template: "std_workshop_face_recognition_shoe_alarm", - VideoSourceRef: "line_cam_02", - DisplayName: "视觉识别终端-B厂区", - PublishHLSPath: "./web/hls/cam2/index.m3u8", - PublishRTSPPort: "8556", - PublishRTSPPath: "/live/cam2", - ChannelNo: "cam2", - }, - }, - } + svc := NewConfigPreviewService(&config.Config{}) + editor := ConfigProfileEditor{ + Name: "local_3588_test", + BusinessName: "A厂区视觉识别", + Description: "test profile", + OverlayName: "face_debug", + DeviceCode: "rk3588-a-001", + SiteName: "A厂区", + Instances: []ConfigProfileInstanceEditor{ + { + Name: "cam1", + Template: "std_workshop_face_recognition_shoe_alarm", + VideoSourceRef: "gate_cam_01", + DisplayName: "东门入口", + PublishHLSPath: "./web/hls/cam1/index.m3u8", + PublishRTSPPort: "8555", + PublishRTSPPath: "/live/cam1", + ChannelNo: "cam1", + AdvancedParams: map[string]any{ + "queue_debug": true, + }, + }, + { + Name: "cam2", + Template: "std_workshop_face_recognition_shoe_alarm", + VideoSourceRef: "line_cam_02", + DisplayName: "视觉识别终端-B厂区", + PublishHLSPath: "./web/hls/cam2/index.m3u8", + PublishRTSPPort: "8556", + PublishRTSPPath: "/live/cam2", + ChannelNo: "cam2", + }, + }, + } - doc, err := svc.BuildProfileDocument(editor) - if err != nil { - t.Fatalf("BuildProfileDocument: %v", err) - } + doc, err := svc.BuildProfileDocument(editor) + if err != nil { + t.Fatalf("BuildProfileDocument: %v", err) + } - if doc["name"] != "local_3588_test" { - t.Fatalf("unexpected doc name: %#v", doc) - } - if doc["business_name"] != "A厂区视觉识别" { - t.Fatalf("unexpected business name: %#v", doc) - } - if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" { - t.Fatalf("unexpected overlays doc: %#v", doc["overlays"]) - } - queue, _ := doc["queue"].(map[string]any) - if queue["size"] != 8 || queue["strategy"] != "drop_oldest" { - t.Fatalf("unexpected queue doc: %#v", queue) - } - instances, _ := doc["instances"].([]map[string]any) - if len(instances) != 2 { - t.Fatalf("expected two instances, got %#v", doc["instances"]) - } - params, _ := instances[0]["params"].(map[string]any) - if params["queue_debug"] != true { - t.Fatalf("expected advanced param to survive rebuild, got %#v", params) - } - if _, exists := params["video_source_ref"]; exists { - t.Fatalf("expected new profile document to avoid legacy video_source_ref in params, got %#v", params) - } - params2, _ := instances[1]["params"].(map[string]any) - if len(params2) != 0 { - t.Fatalf("expected second instance params to stay empty under new model, got %#v", params2) - } - sceneMeta, _ := instances[0]["scene_meta"].(map[string]any) - if sceneMeta["display_name"] != "东门入口" || sceneMeta["site_name"] != "A厂区" || sceneMeta["device_code"] != "rk3588-a-001" { - t.Fatalf("expected scene meta to carry scene fields, got %#v", sceneMeta) - } + if doc["name"] != "local_3588_test" { + t.Fatalf("unexpected doc name: %#v", doc) + } + if doc["business_name"] != "A厂区视觉识别" { + t.Fatalf("unexpected business name: %#v", doc) + } + if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" { + t.Fatalf("unexpected overlays doc: %#v", doc["overlays"]) + } + queue, _ := doc["queue"].(map[string]any) + if queue["size"] != 8 || queue["strategy"] != "drop_oldest" { + t.Fatalf("unexpected queue doc: %#v", queue) + } + instances, _ := doc["instances"].([]map[string]any) + if len(instances) != 2 { + t.Fatalf("expected two instances, got %#v", doc["instances"]) + } + params, _ := instances[0]["params"].(map[string]any) + if params["queue_debug"] != true { + t.Fatalf("expected advanced param to survive rebuild, got %#v", params) + } + if _, exists := params["video_source_ref"]; exists { + t.Fatalf("expected new profile document to avoid legacy video_source_ref in params, got %#v", params) + } + params2, _ := instances[1]["params"].(map[string]any) + if len(params2) != 0 { + t.Fatalf("expected second instance params to stay empty under new model, got %#v", params2) + } + sceneMeta, _ := instances[0]["scene_meta"].(map[string]any) + if sceneMeta["display_name"] != "东门入口" || sceneMeta["site_name"] != "A厂区" || sceneMeta["device_code"] != "rk3588-a-001" { + t.Fatalf("expected scene meta to carry scene fields, got %#v", sceneMeta) + } } func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) { - svc := NewConfigPreviewService(&config.Config{}) - doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ - Name: "line_a", - Instances: []ConfigProfileInstanceEditor{{ - Name: "cam1", - Template: "std_workshop_face_recognition_shoe_alarm", - DisplayName: "B厂区通道1", - SiteName: "B厂区", - InputBindings: map[string]InputBindingEditor{ - "video_input_main": {VideoSourceRef: "gate_cam_01"}, - }, - ServiceBindings: map[string]ServiceBindingEditor{ - "object_storage_main": {ServiceRef: "minio_main"}, - "token_service_main": {ServiceRef: "token_main"}, - "alarm_service_main": {ServiceRef: "alarm_main"}, - }, - OutputBindings: map[string]OutputBindingEditor{ - "stream_output_main": { - PublishHLSPath: "./web/hls/cam1/index.m3u8", - PublishRTSPPort: "8555", - PublishRTSPPath: "/live/cam1", - ChannelNo: "cam1", - }, - }, - }}, - }) - if err != nil { - t.Fatalf("BuildProfileDocument: %v", err) - } - instances, _ := doc["instances"].([]map[string]any) - if len(instances) != 1 { - t.Fatalf("expected one instance, got %#v", doc["instances"]) - } - inst := instances[0] - inputBindings, _ := inst["input_bindings"].(map[string]any) - if inputBindings == nil { - t.Fatalf("expected input_bindings, got %#v", inst) - } - videoInput, _ := inputBindings["video_input_main"].(map[string]any) - if videoInput["video_source_ref"] != "gate_cam_01" { - t.Fatalf("unexpected input binding: %#v", videoInput) - } - serviceBindings, _ := inst["service_bindings"].(map[string]any) - objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) - if objectStorage["service_ref"] != "minio_main" { - t.Fatalf("unexpected service binding: %#v", serviceBindings) - } - outputBindings, _ := inst["output_bindings"].(map[string]any) - streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) - if streamOutput["publish_rtsp_port"] != 8555 { - t.Fatalf("unexpected output binding: %#v", streamOutput) - } - sceneMeta, _ := inst["scene_meta"].(map[string]any) - if sceneMeta["display_name"] != "B厂区通道1" || sceneMeta["site_name"] != "B厂区" { - t.Fatalf("unexpected scene meta: %#v", sceneMeta) - } + svc := NewConfigPreviewService(&config.Config{}) + doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ + Name: "line_a", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam1", + Template: "std_workshop_face_recognition_shoe_alarm", + DisplayName: "B厂区通道1", + SiteName: "B厂区", + InputBindings: map[string]InputBindingEditor{ + "video_input_main": {VideoSourceRef: "gate_cam_01"}, + }, + ServiceBindings: map[string]ServiceBindingEditor{ + "object_storage_main": {ServiceRef: "minio_main"}, + "token_service_main": {ServiceRef: "token_main"}, + "alarm_service_main": {ServiceRef: "alarm_main"}, + }, + OutputBindings: map[string]OutputBindingEditor{ + "stream_output_main": { + PublishHLSPath: "./web/hls/cam1/index.m3u8", + PublishRTSPPort: "8555", + PublishRTSPPath: "/live/cam1", + ChannelNo: "cam1", + }, + }, + }}, + }) + if err != nil { + t.Fatalf("BuildProfileDocument: %v", err) + } + instances, _ := doc["instances"].([]map[string]any) + if len(instances) != 1 { + t.Fatalf("expected one instance, got %#v", doc["instances"]) + } + inst := instances[0] + inputBindings, _ := inst["input_bindings"].(map[string]any) + if inputBindings == nil { + t.Fatalf("expected input_bindings, got %#v", inst) + } + videoInput, _ := inputBindings["video_input_main"].(map[string]any) + if videoInput["video_source_ref"] != "gate_cam_01" { + t.Fatalf("unexpected input binding: %#v", videoInput) + } + serviceBindings, _ := inst["service_bindings"].(map[string]any) + objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) + if objectStorage["service_ref"] != "minio_main" { + t.Fatalf("unexpected service binding: %#v", serviceBindings) + } + outputBindings, _ := inst["output_bindings"].(map[string]any) + streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) + if streamOutput["publish_rtsp_port"] != 8555 { + t.Fatalf("unexpected output binding: %#v", streamOutput) + } + sceneMeta, _ := inst["scene_meta"].(map[string]any) + if sceneMeta["display_name"] != "B厂区通道1" || sceneMeta["site_name"] != "B厂区" { + t.Fatalf("unexpected scene meta: %#v", sceneMeta) + } } func TestBuildProfileDocumentDefaultsStreamOutputFromInstanceName(t *testing.T) { - svc := NewConfigPreviewService(&config.Config{}) - doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ - Name: "line_a", - Instances: []ConfigProfileInstanceEditor{{ - Name: "cam7", - Template: "std_workshop_face_recognition_shoe_alarm", - DisplayName: "B厂区通道7", - InputBindings: map[string]InputBindingEditor{ - "video_input_main": {VideoSourceRef: "gate_cam_07"}, - }, - OutputBindings: map[string]OutputBindingEditor{ - "stream_output_main": { - PublishRTSPPort: "8558", - }, - }, - }}, - }) - if err != nil { - t.Fatalf("BuildProfileDocument: %v", err) - } - instances, _ := doc["instances"].([]map[string]any) - inst := instances[0] - outputBindings, _ := inst["output_bindings"].(map[string]any) - streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) - if streamOutput["publish_hls_path"] != "./web/hls/cam7/index.m3u8" { - t.Fatalf("expected default hls path, got %#v", streamOutput) - } - if streamOutput["publish_rtsp_path"] != "/live/cam7" { - t.Fatalf("expected default rtsp path, got %#v", streamOutput) - } - if streamOutput["channel_no"] != "cam7" { - t.Fatalf("expected default channel no, got %#v", streamOutput) - } - if streamOutput["publish_rtsp_port"] != 8558 { - t.Fatalf("expected explicit rtsp port to be preserved, got %#v", streamOutput) - } + svc := NewConfigPreviewService(&config.Config{}) + doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ + Name: "line_a", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam7", + Template: "std_workshop_face_recognition_shoe_alarm", + DisplayName: "B厂区通道7", + InputBindings: map[string]InputBindingEditor{ + "video_input_main": {VideoSourceRef: "gate_cam_07"}, + }, + OutputBindings: map[string]OutputBindingEditor{ + "stream_output_main": { + PublishRTSPPort: "8558", + }, + }, + }}, + }) + if err != nil { + t.Fatalf("BuildProfileDocument: %v", err) + } + instances, _ := doc["instances"].([]map[string]any) + inst := instances[0] + outputBindings, _ := inst["output_bindings"].(map[string]any) + streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) + if streamOutput["publish_hls_path"] != "./web/hls/cam7/index.m3u8" { + t.Fatalf("expected default hls path, got %#v", streamOutput) + } + if streamOutput["publish_rtsp_path"] != "/live/cam7" { + t.Fatalf("expected default rtsp path, got %#v", streamOutput) + } + if streamOutput["channel_no"] != "cam7" { + t.Fatalf("expected default channel no, got %#v", streamOutput) + } + if streamOutput["publish_rtsp_port"] != 8558 { + t.Fatalf("expected explicit rtsp port to be preserved, got %#v", streamOutput) + } } func TestListVideoSources(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveVideoSource( - "gate_cam_01", - "rtsp", - "东门入口", - "东门主入口摄像头", - `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264","focal_length":"4mm","mount_height":"3.2m","mount_angle":"15deg"}}`, - ); err != nil { - t.Fatalf("SaveVideoSource: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264","focal_length":"4mm","mount_height":"3.2m","mount_angle":"15deg"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } - svc := NewConfigPreviewService(&config.Config{}, repo) - items, err := svc.ListVideoSources() - if err != nil { - t.Fatalf("ListVideoSources: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected 1 video source, got %#v", items) - } - if items[0].SourceType != "rtsp" || items[0].SourceTypeLabel != "RTSP" { - t.Fatalf("unexpected video source summary: %#v", items[0]) - } - if items[0].Config.Resolution != "1080p" || items[0].Config.FrameSize != "1920x1080" { - t.Fatalf("unexpected video source config: %#v", items[0]) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + items, err := svc.ListVideoSources() + if err != nil { + t.Fatalf("ListVideoSources: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 video source, got %#v", items) + } + if items[0].SourceType != "rtsp" || items[0].SourceTypeLabel != "RTSP" { + t.Fatalf("unexpected video source summary: %#v", items[0]) + } + if items[0].Config.Resolution != "1080p" || items[0].Config.FrameSize != "1920x1080" { + t.Fatalf("unexpected video source config: %#v", items[0]) + } } func TestDeleteVideoSourceBlocksWhenReferenced(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveVideoSource( - "gate_cam_01", - "rtsp", - "东门入口", - "东门主入口摄像头", - `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, - ); err != nil { - t.Fatalf("SaveVideoSource: %v", err) - } - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } - svc := NewConfigPreviewService(&config.Config{}, repo) - err = svc.DeleteVideoSource("gate_cam_01") - if err == nil || !strings.Contains(err.Error(), "已被场景模板引用") { - t.Fatalf("expected referenced delete to be blocked, got %v", err) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + err = svc.DeleteVideoSource("gate_cam_01") + if err == nil || !strings.Contains(err.Error(), "已被场景模板引用") { + t.Fatalf("expected referenced delete to be blocked, got %v", err) + } } func TestSaveVideoSourceAssetAllowsChineseName(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) - err = svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{ - Name: "东门主入口", - SourceType: "rtsp", - Area: "东门", - Description: "入口相机", - Config: VideoSourceConfig{ - URL: "rtsp://10.0.0.1/live", - }, - }) - if err != nil { - t.Fatalf("expected chinese video source name to be accepted, got %v", err) - } - item, err := svc.GetVideoSource("东门主入口") - if err != nil { - t.Fatalf("GetVideoSource: %v", err) - } - if item == nil || item.Name != "东门主入口" { - t.Fatalf("unexpected saved item: %#v", item) - } + svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) + err = svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{ + Name: "东门主入口", + SourceType: "rtsp", + Area: "东门", + Description: "入口相机", + Config: VideoSourceConfig{ + URL: "rtsp://10.0.0.1/live", + }, + }) + if err != nil { + t.Fatalf("expected chinese video source name to be accepted, got %v", err) + } + item, err := svc.GetVideoSource("东门主入口") + if err != nil { + t.Fatalf("GetVideoSource: %v", err) + } + if item == nil || item.Name != "东门主入口" { + t.Fatalf("unexpected saved item: %#v", item) + } } func TestConfigPreviewServiceBuildProfileDocumentRejectsBadPort(t *testing.T) { - svc := NewConfigPreviewService(&config.Config{}) - _, err := svc.BuildProfileDocument(ConfigProfileEditor{ - Name: "local_3588_test", - Instances: []ConfigProfileInstanceEditor{ - { - Name: "cam1", - VideoSourceRef: "gate_cam_01", - DisplayName: "视觉识别终端-A厂区", - PublishRTSPPort: "bad-port", - }, - }, - }) - if err == nil { - t.Fatal("expected invalid port error") - } + svc := NewConfigPreviewService(&config.Config{}) + _, err := svc.BuildProfileDocument(ConfigProfileEditor{ + Name: "local_3588_test", + Instances: []ConfigProfileInstanceEditor{ + { + Name: "cam1", + VideoSourceRef: "gate_cam_01", + DisplayName: "视觉识别终端-A厂区", + PublishRTSPPort: "bad-port", + }, + }, + }) + if err == nil { + t.Fatal("expected invalid port error") + } } func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) { - svc := NewConfigPreviewService(&config.Config{}) - doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ - Name: "local_3588_test", - Instances: []ConfigProfileInstanceEditor{ - { - Name: "cam1", - VideoSourceRef: "gate_cam_01", - DisplayName: "视觉识别终端-A厂区", - }, - }, - }) - if err != nil { - t.Fatalf("BuildProfileDocument: %v", err) - } - body, err := json.Marshal(doc) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - if len(body) == 0 { - t.Fatal("expected json body") - } + svc := NewConfigPreviewService(&config.Config{}) + doc, err := svc.BuildProfileDocument(ConfigProfileEditor{ + Name: "local_3588_test", + Instances: []ConfigProfileInstanceEditor{ + { + Name: "cam1", + VideoSourceRef: "gate_cam_01", + DisplayName: "视觉识别终端-A厂区", + }, + }, + }) + if err != nil { + t.Fatalf("BuildProfileDocument: %v", err) + } + body, err := json.Marshal(doc) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if len(body) == 0 { + t.Fatal("expected json body") + } } func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil { - t.Fatalf("SaveOverlay: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil { + t.Fatalf("SaveOverlay: %v", err) + } - svc := NewConfigPreviewService(&config.Config{}, repo) - sources, err := svc.ListSources() - if err != nil { - t.Fatalf("ListSources: %v", err) - } - if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { - t.Fatalf("unexpected templates: %#v", got) - } - if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "gate_a" { - t.Fatalf("unexpected profiles: %#v", got) - } - if got := sourceNames(sources.Overlays); len(got) != 1 || got[0] != "night_relaxed" { - t.Fatalf("unexpected overlays: %#v", got) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + sources, err := svc.ListSources() + if err != nil { + t.Fatalf("ListSources: %v", err) + } + if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { + t.Fatalf("unexpected templates: %#v", got) + } + if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "gate_a" { + t.Fatalf("unexpected profiles: %#v", got) + } + if got := sourceNames(sources.Overlays); len(got) != 1 || got[0] != "night_relaxed" { + t.Fatalf("unexpected overlays: %#v", got) + } } func TestListIntegrationServices(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveIntegrationService( - "minio_primary", - "object_storage", - "primary object store", - false, - `{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false,"config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, - ); err != nil { - t.Fatalf("SaveIntegrationService: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + false, + `{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false,"config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } - svc := NewConfigPreviewService(&config.Config{}, repo) - items, err := svc.ListIntegrationServices() - if err != nil { - t.Fatalf("ListIntegrationServices: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected 1 integration service, got %#v", items) - } - if items[0].Name != "minio_primary" || items[0].Type != "object_storage" || items[0].Enabled { - t.Fatalf("unexpected integration service summary: %#v", items[0]) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + items, err := svc.ListIntegrationServices() + if err != nil { + t.Fatalf("ListIntegrationServices: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 integration service, got %#v", items) + } + if items[0].Name != "minio_primary" || items[0].Type != "object_storage" || items[0].Enabled { + t.Fatalf("unexpected integration service summary: %#v", items[0]) + } - item, err := svc.GetIntegrationService("minio_primary") - if err != nil { - t.Fatalf("GetIntegrationService: %v", err) - } - if item == nil { - t.Fatal("expected integration service") - } - if item.Description != "primary object store" { - t.Fatalf("unexpected integration service description: %#v", item) - } - if item.Type != "object_storage" || item.Enabled { - t.Fatalf("unexpected integration service status: %#v", item) - } - if got := stringValue(item.Raw["type"]); got != "object_storage" { - t.Fatalf("expected raw type to come from body_json, got %#v", item.Raw) - } - if got := stringValue(item.Raw["provider"]); got != "minio" { - t.Fatalf("unexpected integration service provider: %#v", item.Raw) - } - if item.TypeLabel != "对象存储" || item.AddressSummary != "http://10.0.0.49:9000 / myminio" { - t.Fatalf("unexpected integration display fields: %#v", item) - } - if item.ObjectStorage == nil || item.ObjectStorage.Bucket != "myminio" { - t.Fatalf("expected object storage details, got %#v", item) - } - configMap, _ := item.Raw["config"].(map[string]any) - if got := stringValue(configMap["endpoint"]); got != "http://10.0.0.49:9000" { - t.Fatalf("unexpected integration service endpoint: %#v", item.Raw) - } - if enabled, ok := item.Raw["enabled"].(bool); !ok || enabled { - t.Fatalf("expected raw enabled=false, got %#v", item.Raw) - } + item, err := svc.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if item == nil { + t.Fatal("expected integration service") + } + if item.Description != "primary object store" { + t.Fatalf("unexpected integration service description: %#v", item) + } + if item.Type != "object_storage" || item.Enabled { + t.Fatalf("unexpected integration service status: %#v", item) + } + if got := stringValue(item.Raw["type"]); got != "object_storage" { + t.Fatalf("expected raw type to come from body_json, got %#v", item.Raw) + } + if got := stringValue(item.Raw["provider"]); got != "minio" { + t.Fatalf("unexpected integration service provider: %#v", item.Raw) + } + if item.TypeLabel != "对象存储" || item.AddressSummary != "http://10.0.0.49:9000 / myminio" { + t.Fatalf("unexpected integration display fields: %#v", item) + } + if item.ObjectStorage == nil || item.ObjectStorage.Bucket != "myminio" { + t.Fatalf("expected object storage details, got %#v", item) + } + configMap, _ := item.Raw["config"].(map[string]any) + if got := stringValue(configMap["endpoint"]); got != "http://10.0.0.49:9000" { + t.Fatalf("unexpected integration service endpoint: %#v", item.Raw) + } + if enabled, ok := item.Raw["enabled"].(bool); !ok || enabled { + t.Fatalf("expected raw enabled=false, got %#v", item.Raw) + } } func TestListIntegrationServicesCountsProfileReferences(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveIntegrationService( - "minio_primary", - "object_storage", - "primary object store", - true, - `{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, - ); err != nil { - t.Fatalf("SaveIntegrationService: %v", err) - } - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + true, + `{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } - svc := NewConfigPreviewService(&config.Config{}, repo) - items, err := svc.ListIntegrationServices() - if err != nil { - t.Fatalf("ListIntegrationServices: %v", err) - } - if len(items) != 1 || items[0].RefCount != 1 { - t.Fatalf("expected one referenced service, got %#v", items) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + items, err := svc.ListIntegrationServices() + if err != nil { + t.Fatalf("ListIntegrationServices: %v", err) + } + if len(items) != 1 || items[0].RefCount != 1 { + t.Fatalf("expected one referenced service, got %#v", items) + } } func TestGetIntegrationServiceNotFound(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) - item, err := svc.GetIntegrationService("missing_service") - if !strings.Contains(err.Error(), "file does not exist") && !os.IsNotExist(err) { - t.Fatalf("expected not found error, got item=%#v err=%v", item, err) - } - if item != nil { - t.Fatalf("expected nil item for missing integration service, got %#v", item) - } + svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) + item, err := svc.GetIntegrationService("missing_service") + if !strings.Contains(err.Error(), "file does not exist") && !os.IsNotExist(err) { + t.Fatalf("expected not found error, got item=%#v err=%v", item, err) + } + if item != nil { + t.Fatalf("expected nil item for missing integration service, got %#v", item) + } } func TestGetIntegrationServicePrefersRecordTypeOverRawType(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveIntegrationService( - "minio_primary", - "object_storage", - "primary object store", - true, - `{"name":"minio_primary","type":"minio","provider":"minio","enabled":true}`, - ); err != nil { - t.Fatalf("SaveIntegrationService: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + true, + `{"name":"minio_primary","type":"minio","provider":"minio","enabled":true}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } - svc := NewConfigPreviewService(&config.Config{}, repo) - item, err := svc.GetIntegrationService("minio_primary") - if err != nil { - t.Fatalf("GetIntegrationService: %v", err) - } - if item.Type != "object_storage" { - t.Fatalf("expected canonical type from record, got %#v", item) - } - if got := stringValue(item.Raw["type"]); got != "minio" { - t.Fatalf("expected raw type to preserve original body_json, got %#v", item.Raw) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + item, err := svc.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if item.Type != "object_storage" { + t.Fatalf("expected canonical type from record, got %#v", item) + } + if got := stringValue(item.Raw["type"]); got != "minio" { + t.Fatalf("expected raw type to preserve original body_json, got %#v", item.Raw) + } } func TestDeleteIntegrationServiceBlocksWhenReferenced(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveIntegrationService( - "minio_primary", - "object_storage", - "primary object store", - true, - `{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, - ); err != nil { - t.Fatalf("SaveIntegrationService: %v", err) - } - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + true, + `{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } - svc := NewConfigPreviewService(&config.Config{}, repo) - err = svc.DeleteIntegrationService("minio_primary") - if err == nil || !strings.Contains(err.Error(), "used by scene configs") { - t.Fatalf("expected referenced delete to be blocked, got %v", err) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + err = svc.DeleteIntegrationService("minio_primary") + if err == nil || !strings.Contains(err.Error(), "used by scene configs") { + t.Fatalf("expected referenced delete to be blocked, got %v", err) + } } func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - svc := NewConfigPreviewService(&config.Config{}, repo) - editor := ConfigProfileEditor{ - Name: "gate_a", - BusinessName: "厂区入口", - Description: "白班识别", - SiteName: "A厂区", - Instances: []ConfigProfileInstanceEditor{ - { - Name: "cam1", - Template: "helmet", - DisplayName: "东门入口", - VideoSourceRef: "gate_cam_01", - }, - }, - } + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{}, repo) + editor := ConfigProfileEditor{ + Name: "gate_a", + BusinessName: "厂区入口", + Description: "白班识别", + SiteName: "A厂区", + Instances: []ConfigProfileInstanceEditor{ + { + Name: "cam1", + Template: "helmet", + DisplayName: "东门入口", + VideoSourceRef: "gate_cam_01", + }, + }, + } - if err := svc.SaveProfileEditor(editor); err != nil { - t.Fatalf("SaveProfileEditor: %v", err) - } + if err := svc.SaveProfileEditor(editor); err != nil { + t.Fatalf("SaveProfileEditor: %v", err) + } - saved, err := repo.GetProfile("gate_a") - if err != nil { - t.Fatalf("GetProfile: %v", err) - } - if saved == nil { - t.Fatal("expected saved profile") - } - if saved.BusinessName != "厂区入口" { - t.Fatalf("expected business name, got %#v", saved) - } - if saved.TemplateName != "helmet" { - t.Fatalf("expected template name to be inferred, got %#v", saved) - } - if saved.Description != "白班识别" { - t.Fatalf("expected description, got %#v", saved) - } + saved, err := repo.GetProfile("gate_a") + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if saved == nil { + t.Fatal("expected saved profile") + } + if saved.BusinessName != "厂区入口" { + t.Fatalf("expected business name, got %#v", saved) + } + if saved.TemplateName != "helmet" { + t.Fatalf("expected template name to be inferred, got %#v", saved) + } + if saved.Description != "白班识别" { + t.Fatalf("expected description, got %#v", saved) + } } func TestConfigPreviewServicePrefersRepoTemplateOverBuiltinFallback(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{ + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{ "name": "helmet", "description": "builtin template", "template": {"nodes": [{"id":"input_rtsp_main"}], "edges": []} }`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "shadow template", `{"name":"helmet","description":"shadow template","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "shadow template", `{"name":"helmet","description":"shadow template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - item, err := svc.GetTemplateAsset("helmet") - if err != nil { - t.Fatalf("GetTemplateAsset: %v", err) - } - if item.ReadOnly || item.Origin != "user" { - t.Fatalf("expected sqlite template to be preferred, got %#v", item) - } - if item.Description != "shadow template" { - t.Fatalf("expected sqlite template payload, got %#v", item) - } - items, err := svc.ListTemplateAssets() - if err != nil { - t.Fatalf("ListTemplateAssets: %v", err) - } - if len(items) != 1 || items[0].Name != "helmet" || items[0].ReadOnly { - t.Fatalf("expected only sqlite template in merged list, got %#v", items) - } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + item, err := svc.GetTemplateAsset("helmet") + if err != nil { + t.Fatalf("GetTemplateAsset: %v", err) + } + if item.ReadOnly || item.Origin != "user" { + t.Fatalf("expected sqlite template to be preferred, got %#v", item) + } + if item.Description != "shadow template" { + t.Fatalf("expected sqlite template payload, got %#v", item) + } + items, err := svc.ListTemplateAssets() + if err != nil { + t.Fatalf("ListTemplateAssets: %v", err) + } + if len(items) != 1 || items[0].Name != "helmet" || items[0].ReadOnly { + t.Fatalf("expected only sqlite template in merged list, got %#v", items) + } } func TestConfigPreviewServiceRejectsSavingStandardTemplateName(t *testing.T) { - root := t.TempDir() - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + root := t.TempDir() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - err = svc.SaveTemplateAsset("std_face_recognition_stream", "new body", `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`) - if err == nil || !strings.Contains(err.Error(), "read-only") { - t.Fatalf("expected readonly rejection, got %v", err) - } + err = svc.SaveTemplateAsset("std_face_recognition_stream", "new body", `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`) + if err == nil || !strings.Contains(err.Error(), "read-only") { + t.Fatalf("expected readonly rejection, got %v", err) + } } func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - svc := NewConfigPreviewService(&config.Config{}, repo) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + svc := NewConfigPreviewService(&config.Config{}, repo) - err = svc.RenameTemplateAsset("helmet", "helmet_v2", "helmet v2", `{"name":"helmet_v2","description":"helmet v2","template":{"nodes":[],"edges":[]}}`) - if err != nil { - t.Fatalf("RenameTemplateAsset: %v", err) - } + err = svc.RenameTemplateAsset("helmet", "helmet_v2", "helmet v2", `{"name":"helmet_v2","description":"helmet v2","template":{"nodes":[],"edges":[]}}`) + if err != nil { + t.Fatalf("RenameTemplateAsset: %v", err) + } - record, err := repo.GetTemplate("helmet_v2") - if err != nil { - t.Fatalf("GetTemplate: %v", err) - } - if record == nil || !strings.Contains(record.BodyJSON, `"name":"helmet_v2"`) { - t.Fatalf("expected renamed template, got %#v", record) - } - profile, err := repo.GetProfile("gate_a") - if err != nil { - t.Fatalf("GetProfile: %v", err) - } - if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`)) { - t.Fatalf("expected updated profile refs, got %#v", profile) - } + record, err := repo.GetTemplate("helmet_v2") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if record == nil || !strings.Contains(record.BodyJSON, `"name":"helmet_v2"`) { + t.Fatalf("expected renamed template, got %#v", record) + } + profile, err := repo.GetProfile("gate_a") + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`)) { + t.Fatalf("expected updated profile refs, got %#v", profile) + } } func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - svc := NewConfigPreviewService(&config.Config{}, repo) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + svc := NewConfigPreviewService(&config.Config{}, repo) - err = svc.DeleteTemplateAsset("helmet") - if err == nil || !strings.Contains(err.Error(), "used by business configs") { - t.Fatalf("expected reference rejection, got %v", err) - } + err = svc.DeleteTemplateAsset("helmet") + if err == nil || !strings.Contains(err.Error(), "used by business configs") { + t.Fatalf("expected reference rejection, got %v", err) + } } func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`) - mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) - mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`) + mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) + mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - result, err := svc.ImportAssetsFromMediaRepo() - if err != nil { - t.Fatalf("ImportAssetsFromMediaRepo: %v", err) - } - if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { - t.Fatalf("unexpected import result: %#v", result) - } - record, err := repo.GetTemplate("std_face_recognition_stream") - if err != nil { - t.Fatalf("GetTemplate: %v", err) - } - if record == nil || !strings.Contains(record.BodyJSON, `"source": "standard"`) && !strings.Contains(record.BodyJSON, `"source":"standard"`) { - t.Fatalf("expected standard template to be imported into sqlite, got %#v", record) - } + result, err := svc.ImportAssetsFromMediaRepo() + if err != nil { + t.Fatalf("ImportAssetsFromMediaRepo: %v", err) + } + if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { + t.Fatalf("unexpected import result: %#v", result) + } + record, err := repo.GetTemplate("std_face_recognition_stream") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if record == nil || !strings.Contains(record.BodyJSON, `"source": "standard"`) && !strings.Contains(record.BodyJSON, `"source":"standard"`) { + t.Fatalf("expected standard template to be imported into sqlite, got %#v", record) + } } func TestImportStandardTemplatesFromDirSyncsExisting(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`) - mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("std_service_test_stream", "existing service", `{"name":"std_service_test_stream","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } + root := t.TempDir() + mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`) + mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("std_service_test_stream", "existing service", `{"name":"std_service_test_stream","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } - imported, err := ImportStandardTemplatesFromDir(repo, root) - if err != nil { - t.Fatalf("ImportStandardTemplatesFromDir: %v", err) - } - if imported != 2 { - t.Fatalf("expected two synced standard templates, got %d", imported) - } - face, err := repo.GetTemplate("std_face_recognition_stream") - if err != nil || face == nil { - t.Fatalf("expected imported face template, got %#v err=%v", face, err) - } - if !strings.Contains(face.BodyJSON, `"source": "standard"`) && !strings.Contains(face.BodyJSON, `"source":"standard"`) { - t.Fatalf("expected imported template source marker, got %#v", face) - } - serviceRecord, err := repo.GetTemplate("std_service_test_stream") - if err != nil || serviceRecord == nil { - t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err) - } - if serviceRecord.Description != "standard service" { - t.Fatalf("expected existing template to sync from directory, got %#v", serviceRecord) - } - if !strings.Contains(serviceRecord.BodyJSON, `"description": "standard service"`) && !strings.Contains(serviceRecord.BodyJSON, `"description":"standard service"`) { - t.Fatalf("expected synced template body, got %#v", serviceRecord) - } + imported, err := ImportStandardTemplatesFromDir(repo, root) + if err != nil { + t.Fatalf("ImportStandardTemplatesFromDir: %v", err) + } + if imported != 2 { + t.Fatalf("expected two synced standard templates, got %d", imported) + } + face, err := repo.GetTemplate("std_face_recognition_stream") + if err != nil || face == nil { + t.Fatalf("expected imported face template, got %#v err=%v", face, err) + } + if !strings.Contains(face.BodyJSON, `"source": "standard"`) && !strings.Contains(face.BodyJSON, `"source":"standard"`) { + t.Fatalf("expected imported template source marker, got %#v", face) + } + serviceRecord, err := repo.GetTemplate("std_service_test_stream") + if err != nil || serviceRecord == nil { + t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err) + } + if serviceRecord.Description != "standard service" { + t.Fatalf("expected existing template to sync from directory, got %#v", serviceRecord) + } + if !strings.Contains(serviceRecord.BodyJSON, `"description": "standard service"`) && !strings.Contains(serviceRecord.BodyJSON, `"description":"standard service"`) { + t.Fatalf("expected synced template body, got %#v", serviceRecord) + } } func TestBuildDeviceAssignmentBoardDataSummarizesLoad(t *testing.T) { - devices := []*models.Device{ - {DeviceID: "edge-01", DeviceName: "设备一"}, - {DeviceID: "edge-02", DeviceName: "设备二"}, - {DeviceID: "edge-03", DeviceName: "设备三"}, - {DeviceID: "edge-04", DeviceName: "设备四"}, - } - units := []RecognitionUnitAsset{ - {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1"}, - {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2", VideoSourceRef: "vs2"}, - {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3", VideoSourceRef: "vs3"}, - {Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4", VideoSourceRef: "vs4"}, - {Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5", VideoSourceRef: "vs5"}, - {Ref: "scene_a::cam6", SceneTemplateName: "scene_a", Name: "cam6", VideoSourceRef: "vs6"}, - } - assignments := []DeviceAssignmentAsset{ - {DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3}, - {DeviceID: "edge-02", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam4"}, RecognitionCount: 1}, - {DeviceID: "edge-03", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam5", "scene_a::cam6"}, RecognitionCount: 2}, - } + devices := []*models.Device{ + {DeviceID: "edge-01", DeviceName: "设备一"}, + {DeviceID: "edge-02", DeviceName: "设备二"}, + {DeviceID: "edge-03", DeviceName: "设备三"}, + {DeviceID: "edge-04", DeviceName: "设备四"}, + } + units := []RecognitionUnitAsset{ + {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1"}, + {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2", VideoSourceRef: "vs2"}, + {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3", VideoSourceRef: "vs3"}, + {Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4", VideoSourceRef: "vs4"}, + {Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5", VideoSourceRef: "vs5"}, + {Ref: "scene_a::cam6", SceneTemplateName: "scene_a", Name: "cam6", VideoSourceRef: "vs6"}, + } + assignments := []DeviceAssignmentAsset{ + {DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3}, + {DeviceID: "edge-02", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam4"}, RecognitionCount: 1}, + {DeviceID: "edge-03", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam5", "scene_a::cam6"}, RecognitionCount: 2}, + } - board := BuildDeviceAssignmentBoardData(devices, assignments, units, 4) + board := BuildDeviceAssignmentBoardData(devices, assignments, units, 4) - if board.Stats.TotalUnits != 6 || board.Stats.TotalDevices != 4 { - t.Fatalf("unexpected totals: %#v", board.Stats) - } - if board.Stats.AssignedUnits != 6 || board.Stats.UnassignedUnits != 0 { - t.Fatalf("unexpected assignment counts: %#v", board.Stats) - } - if board.Stats.OverloadedDevices != 0 { - t.Fatalf("expected no full devices in this dataset, got %#v", board.Stats) - } - if len(board.Cards) != 4 { - t.Fatalf("expected four device cards, got %#v", board.Cards) - } - if board.Cards[0].DeviceID != "edge-01" || board.Cards[0].Status != "busy" { - t.Fatalf("expected >50%% loaded device to sort first, got %#v", board.Cards) - } - if board.Cards[1].DeviceID != "edge-03" || board.Cards[1].Status != "busy" { - t.Fatalf("expected 50%% loaded device to count as busy, got %#v", board.Cards) - } - if board.Cards[3].Status != "idle" { - t.Fatalf("expected idle device card, got %#v", board.Cards[3]) - } + if board.Stats.TotalUnits != 6 || board.Stats.TotalDevices != 4 { + t.Fatalf("unexpected totals: %#v", board.Stats) + } + if board.Stats.AssignedUnits != 6 || board.Stats.UnassignedUnits != 0 { + t.Fatalf("unexpected assignment counts: %#v", board.Stats) + } + if board.Stats.OverloadedDevices != 0 { + t.Fatalf("expected no full devices in this dataset, got %#v", board.Stats) + } + if len(board.Cards) != 4 { + t.Fatalf("expected four device cards, got %#v", board.Cards) + } + if board.Cards[0].DeviceID != "edge-01" || board.Cards[0].Status != "busy" { + t.Fatalf("expected >50%% loaded device to sort first, got %#v", board.Cards) + } + if board.Cards[1].DeviceID != "edge-03" || board.Cards[1].Status != "busy" { + t.Fatalf("expected 50%% loaded device to count as busy, got %#v", board.Cards) + } + if board.Cards[3].Status != "idle" { + t.Fatalf("expected idle device card, got %#v", board.Cards[3]) + } } func TestBuildDeviceAssignmentBoardDataTreatsOverMaxAsFull(t *testing.T) { - devices := []*models.Device{{DeviceID: "edge-01", DeviceName: "设备一"}} - units := []RecognitionUnitAsset{ - {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"}, - {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"}, - {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"}, - } - assignments := []DeviceAssignmentAsset{ - {DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3}, - } + devices := []*models.Device{{DeviceID: "edge-01", DeviceName: "设备一"}} + units := []RecognitionUnitAsset{ + {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"}, + {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"}, + {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"}, + } + assignments := []DeviceAssignmentAsset{ + {DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3}, + } - board := BuildDeviceAssignmentBoardData(devices, assignments, units, 2) + board := BuildDeviceAssignmentBoardData(devices, assignments, units, 2) - if board.Cards[0].Status != "full" { - t.Fatalf("expected over-max card to collapse into full, got %#v", board.Cards[0]) - } - if board.Stats.OverloadedDevices != 1 { - t.Fatalf("expected full-device counter to include over-max card, got %#v", board.Stats) - } + if board.Cards[0].Status != "full" { + t.Fatalf("expected over-max card to collapse into full, got %#v", board.Cards[0]) + } + if board.Stats.OverloadedDevices != 1 { + t.Fatalf("expected full-device counter to include over-max card, got %#v", board.Stats) + } } func TestBuildAutoDeviceAssignmentsBalancesUnits(t *testing.T) { - devices := []*models.Device{ - {DeviceID: "edge-01"}, - {DeviceID: "edge-02"}, - {DeviceID: "edge-03"}, - } - units := []RecognitionUnitAsset{ - {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"}, - {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"}, - {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"}, - {Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4"}, - {Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5"}, - } + devices := []*models.Device{ + {DeviceID: "edge-01"}, + {DeviceID: "edge-02"}, + {DeviceID: "edge-03"}, + } + units := []RecognitionUnitAsset{ + {Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"}, + {Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"}, + {Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"}, + {Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4"}, + {Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5"}, + } - assignments := BuildAutoDeviceAssignments(devices, units, 2) - if len(assignments) != 3 { - t.Fatalf("expected three populated device assignments, got %#v", assignments) - } - counts := map[string]int{} - total := 0 - for _, item := range assignments { - counts[item.DeviceID] = len(item.RecognitionUnits) - total += len(item.RecognitionUnits) - if item.ProfileName != "scene_a" { - t.Fatalf("expected scene template preserved, got %#v", item) - } - if len(item.RecognitionUnits) > 2 { - t.Fatalf("expected max two units per device, got %#v", item) - } - } - if total != 5 { - t.Fatalf("expected all units assigned, got %#v", assignments) - } - if counts["edge-01"] != 2 || counts["edge-02"] != 2 || counts["edge-03"] != 1 { - t.Fatalf("expected balanced distribution, got %#v", counts) - } + assignments := BuildAutoDeviceAssignments(devices, units, 2) + if len(assignments) != 3 { + t.Fatalf("expected three populated device assignments, got %#v", assignments) + } + counts := map[string]int{} + total := 0 + for _, item := range assignments { + counts[item.DeviceID] = len(item.RecognitionUnits) + total += len(item.RecognitionUnits) + if item.ProfileName != "scene_a" { + t.Fatalf("expected scene template preserved, got %#v", item) + } + if len(item.RecognitionUnits) > 2 { + t.Fatalf("expected max two units per device, got %#v", item) + } + } + if total != 5 { + t.Fatalf("expected all units assigned, got %#v", assignments) + } + if counts["edge-01"] != 2 || counts["edge-02"] != 2 || counts["edge-03"] != 1 { + t.Fatalf("expected balanced distribution, got %#v", counts) + } } func TestSaveDeviceAssignmentBoardPersistsAssignments(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ - SceneTemplateName: "scene_a", - Name: "cam1", - VideoSourceRef: "vs1", - OutputChannel: "cam1", - RTSPPort: "8555", - BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, - }); err != nil { - t.Fatalf("SaveRecognitionUnit cam1: %v", err) - } - if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ - SceneTemplateName: "scene_a", - Name: "cam2", - VideoSourceRef: "vs2", - OutputChannel: "cam2", - RTSPPort: "8555", - BodyJSON: `{"name":"cam2","input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`, - }); err != nil { - t.Fatalf("SaveRecognitionUnit cam2: %v", err) - } - svc := NewConfigPreviewService(&config.Config{}, repo) - if err := svc.SaveDeviceAssignmentBoard(map[string][]string{ - "edge-01": []string{"scene_a::cam1", "scene_a::cam2"}, - "edge-02": []string{}, - }); err != nil { - t.Fatalf("SaveDeviceAssignmentBoard: %v", err) - } - item, err := svc.GetDeviceAssignment("edge-01") - if err != nil { - t.Fatalf("GetDeviceAssignment: %v", err) - } - if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 2 { - t.Fatalf("unexpected saved assignment: %#v", item) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ + SceneTemplateName: "scene_a", + Name: "cam1", + VideoSourceRef: "vs1", + OutputChannel: "cam1", + RTSPPort: "8555", + BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, + }); err != nil { + t.Fatalf("SaveRecognitionUnit cam1: %v", err) + } + if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ + SceneTemplateName: "scene_a", + Name: "cam2", + VideoSourceRef: "vs2", + OutputChannel: "cam2", + RTSPPort: "8555", + BodyJSON: `{"name":"cam2","input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`, + }); err != nil { + t.Fatalf("SaveRecognitionUnit cam2: %v", err) + } + svc := NewConfigPreviewService(&config.Config{}, repo) + if err := svc.SaveDeviceAssignmentBoard(map[string][]string{ + "edge-01": []string{"scene_a::cam1", "scene_a::cam2"}, + "edge-02": []string{}, + }); err != nil { + t.Fatalf("SaveDeviceAssignmentBoard: %v", err) + } + item, err := svc.GetDeviceAssignment("edge-01") + if err != nil { + t.Fatalf("GetDeviceAssignment: %v", err) + } + if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 2 { + t.Fatalf("unexpected saved assignment: %#v", item) + } } diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go index ba242af..19f797f 100644 --- a/internal/service/config_preview.go +++ b/internal/service/config_preview.go @@ -1,620 +1,620 @@ package service import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/storage" ) var safeConfigName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) type ConfigPreviewService struct { - cfg *config.Config - assets *storage.AssetsRepo + cfg *config.Config + assets *storage.AssetsRepo } type ConfigSource struct { - Name string `json:"name"` - Path string `json:"path"` + Name string `json:"name"` + Path string `json:"path"` } type ConfigPreviewSources struct { - Root string `json:"root"` - Templates []ConfigSource `json:"templates"` - Profiles []ConfigSource `json:"profiles"` - Overlays []ConfigSource `json:"overlays"` + Root string `json:"root"` + Templates []ConfigSource `json:"templates"` + Profiles []ConfigSource `json:"profiles"` + Overlays []ConfigSource `json:"overlays"` } type ConfigPreviewRequest struct { - Template string - Profile string - Overlays []string - ConfigID string - ConfigVersion string - DeviceID string + Template string + Profile string + Overlays []string + ConfigID string + ConfigVersion string + DeviceID string } type ConfigPreviewResult struct { - Request ConfigPreviewRequest `json:"request"` - Root string `json:"root"` - Sha256 string `json:"sha256"` - Size int `json:"size"` - Metadata map[string]any `json:"metadata"` - JSON string `json:"json"` + Request ConfigPreviewRequest `json:"request"` + Root string `json:"root"` + Sha256 string `json:"sha256"` + Size int `json:"size"` + Metadata map[string]any `json:"metadata"` + JSON string `json:"json"` } type ConfigAssetImportResult struct { - Root string `json:"root"` - Templates int `json:"templates"` - Profiles int `json:"profiles"` - Overlays int `json:"overlays"` + Root string `json:"root"` + Templates int `json:"templates"` + Profiles int `json:"profiles"` + Overlays int `json:"overlays"` } func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *ConfigPreviewService { - var assets *storage.AssetsRepo - if len(repo) > 0 { - assets = repo[0] - } - return &ConfigPreviewService{cfg: cfg, assets: assets} + var assets *storage.AssetsRepo + if len(repo) > 0 { + assets = repo[0] + } + return &ConfigPreviewService{cfg: cfg, assets: assets} } func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) { - out := ConfigPreviewSources{} - if s == nil || s.assets == nil { - return out, nil - } - templates, err := s.assets.ListTemplates() - if err != nil { - return out, err - } - for _, item := range templates { - out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)}) - } - profiles, err := s.assets.ListProfiles() - if err != nil { - return out, err - } - for _, item := range profiles { - out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)}) - } - overlays, err := s.assets.ListOverlays() - if err != nil { - return out, err - } - for _, item := range overlays { - out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)}) - } - sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name }) - sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name }) - sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name }) - if len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0 { - out.Root = "SQLite" - } - return out, nil + out := ConfigPreviewSources{} + if s == nil || s.assets == nil { + return out, nil + } + templates, err := s.assets.ListTemplates() + if err != nil { + return out, err + } + for _, item := range templates { + out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)}) + } + profiles, err := s.assets.ListProfiles() + if err != nil { + return out, err + } + for _, item := range profiles { + out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)}) + } + overlays, err := s.assets.ListOverlays() + if err != nil { + return out, err + } + for _, item := range overlays { + out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)}) + } + sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name }) + sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name }) + sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name }) + if len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0 { + out.Root = "SQLite" + } + return out, nil } func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) { - if err := validateConfigName(req.Template); err != nil { - return nil, fmt.Errorf("invalid template: %w", err) - } - if strings.TrimSpace(req.Profile) != "" { - if err := validateConfigName(req.Profile); err != nil { - return nil, fmt.Errorf("invalid profile: %w", err) - } - } - for _, overlay := range req.Overlays { - if err := validateConfigName(overlay); err != nil { - return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) - } - } - templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template) - if err != nil { - return nil, err - } - profilePath := repoAssetPath("profiles", req.Profile) - profileRaw := map[string]any{} - if strings.TrimSpace(req.Profile) != "" { - editor, err := s.GetProfileEditor(req.Profile) - if err != nil { - return nil, err - } - profileRaw, err = s.BuildProfileDocument(*editor) - if err != nil { - return nil, err - } - } - selectedOverlays := req.Overlays - if len(selectedOverlays) == 0 { - selectedOverlays = profileOverlayNames(profileRaw) - } - overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays)) - for _, overlay := range selectedOverlays { - raw, path, err := s.readAssetJSON("overlays", overlay) - if err != nil { - return nil, err - } - overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) - } - req.Overlays = append([]string(nil), selectedOverlays...) - return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays) + if err := validateConfigName(req.Template); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) + } + if strings.TrimSpace(req.Profile) != "" { + if err := validateConfigName(req.Profile); err != nil { + return nil, fmt.Errorf("invalid profile: %w", err) + } + } + for _, overlay := range req.Overlays { + if err := validateConfigName(overlay); err != nil { + return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) + } + } + templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template) + if err != nil { + return nil, err + } + profilePath := repoAssetPath("profiles", req.Profile) + profileRaw := map[string]any{} + if strings.TrimSpace(req.Profile) != "" { + editor, err := s.GetProfileEditor(req.Profile) + if err != nil { + return nil, err + } + profileRaw, err = s.BuildProfileDocument(*editor) + if err != nil { + return nil, err + } + } + selectedOverlays := req.Overlays + if len(selectedOverlays) == 0 { + selectedOverlays = profileOverlayNames(profileRaw) + } + overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays)) + for _, overlay := range selectedOverlays { + raw, path, err := s.readAssetJSON("overlays", overlay) + if err != nil { + return nil, err + } + overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) + } + req.Overlays = append([]string(nil), selectedOverlays...) + return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays) } func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) { - if err := validateConfigName(req.Template); err != nil { - return nil, fmt.Errorf("invalid template: %w", err) - } - for _, overlay := range req.Overlays { - if err := validateConfigName(overlay); err != nil { - return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) - } - } - doc, err := s.BuildProfileDocument(editor) - if err != nil { - return nil, err - } - if strings.TrimSpace(req.Profile) == "" { - req.Profile = strings.TrimSpace(editor.Name) - } - templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template) - if err != nil { - return nil, err - } - selectedOverlays := req.Overlays - if len(selectedOverlays) == 0 { - selectedOverlays = profileOverlayNames(doc) - } - overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays)) - for _, overlay := range selectedOverlays { - raw, path, err := s.readAssetJSON("overlays", overlay) - if err != nil { - return nil, err - } - overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) - } - req.Overlays = append([]string(nil), selectedOverlays...) - return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays) + if err := validateConfigName(req.Template); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) + } + for _, overlay := range req.Overlays { + if err := validateConfigName(overlay); err != nil { + return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) + } + } + doc, err := s.BuildProfileDocument(editor) + if err != nil { + return nil, err + } + if strings.TrimSpace(req.Profile) == "" { + req.Profile = strings.TrimSpace(editor.Name) + } + templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template) + if err != nil { + return nil, err + } + selectedOverlays := req.Overlays + if len(selectedOverlays) == 0 { + selectedOverlays = profileOverlayNames(doc) + } + overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays)) + for _, overlay := range selectedOverlays { + raw, path, err := s.readAssetJSON("overlays", overlay) + if err != nil { + return nil, err + } + overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw}) + } + req.Overlays = append([]string(nil), selectedOverlays...) + return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays) } func (s *ConfigPreviewService) BuildProfileEditorForDeviceAssignment(deviceID string) (*ConfigProfileEditor, *DeviceAssignmentAsset, error) { - assignment, err := s.GetDeviceAssignment(deviceID) - if err != nil { - return nil, nil, err - } - editor, err := s.GetProfileEditor(assignment.ProfileName) - if err != nil { - return nil, nil, err - } - if len(assignment.RecognitionUnits) == 0 { - editor.Instances = nil - return editor, assignment, nil - } - selected := make(map[string]struct{}, len(assignment.RecognitionUnits)) - for _, ref := range assignment.RecognitionUnits { - selected[ref] = struct{}{} - } - filtered := make([]ConfigProfileInstanceEditor, 0, len(assignment.RecognitionUnits)) - for _, inst := range editor.Instances { - if _, ok := selected[recognitionUnitRef(editor.Name, inst.Name)]; ok { - filtered = append(filtered, inst) - } - } - editor.Instances = filtered - return editor, assignment, nil + assignment, err := s.GetDeviceAssignment(deviceID) + if err != nil { + return nil, nil, err + } + editor, err := s.GetProfileEditor(assignment.ProfileName) + if err != nil { + return nil, nil, err + } + if len(assignment.RecognitionUnits) == 0 { + editor.Instances = nil + return editor, assignment, nil + } + selected := make(map[string]struct{}, len(assignment.RecognitionUnits)) + for _, ref := range assignment.RecognitionUnits { + selected[ref] = struct{}{} + } + filtered := make([]ConfigProfileInstanceEditor, 0, len(assignment.RecognitionUnits)) + for _, inst := range editor.Instances { + if _, ok := selected[recognitionUnitRef(editor.Name, inst.Name)]; ok { + filtered = append(filtered, inst) + } + } + editor.Instances = filtered + return editor, assignment, nil } func (s *ConfigPreviewService) RenderDeviceAssignment(deviceID string) (*ConfigPreviewResult, error) { - editor, assignment, err := s.BuildProfileEditorForDeviceAssignment(deviceID) - if err != nil { - return nil, err - } - if len(editor.Instances) == 0 { - return nil, fmt.Errorf("设备 %s 还没有分配识别单元", deviceID) - } - templateName := firstProfileTemplate(editor.Instances) - if strings.TrimSpace(templateName) == "" { - templateName = editor.RawTemplateName() - } - return s.RenderProfileEditor(*editor, ConfigPreviewRequest{ - Template: templateName, - Profile: assignment.ProfileName, - ConfigID: "assignment_" + deviceID, - ConfigVersion: time.Now().Format("20060102.150405"), - DeviceID: deviceID, - }) + editor, assignment, err := s.BuildProfileEditorForDeviceAssignment(deviceID) + if err != nil { + return nil, err + } + if len(editor.Instances) == 0 { + return nil, fmt.Errorf("设备 %s 还没有分配识别单元", deviceID) + } + templateName := firstProfileTemplate(editor.Instances) + if strings.TrimSpace(templateName) == "" { + templateName = editor.RawTemplateName() + } + return s.RenderProfileEditor(*editor, ConfigPreviewRequest{ + Template: templateName, + Profile: assignment.ProfileName, + ConfigID: "assignment_" + deviceID, + ConfigVersion: time.Now().Format("20060102.150405"), + DeviceID: deviceID, + }) } func profileOverlayNames(raw map[string]any) []string { - items, _ := raw["overlays"].([]any) - if len(items) == 0 { - return nil - } - out := make([]string, 0, len(items)) - for _, item := range items { - if v := stringAny(item); strings.TrimSpace(v) != "" { - out = append(out, strings.TrimSpace(v)) - } - } - if len(out) == 0 { - return nil - } - return out + items, _ := raw["overlays"].([]any) + if len(items) == 0 { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + if v := stringAny(item); strings.TrimSpace(v) != "" { + out = append(out, strings.TrimSpace(v)) + } + } + if len(out) == 0 { + return nil + } + return out } func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) { - if err := validateConfigName(req.Template); err != nil { - return nil, fmt.Errorf("invalid template: %w", err) - } - if strings.TrimSpace(req.Profile) != "" { - if err := validateConfigName(req.Profile); err != nil { - return nil, fmt.Errorf("invalid profile: %w", err) - } - } - for _, overlay := range req.Overlays { - if err := validateConfigName(overlay); err != nil { - return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) - } - } - if req.ConfigID == "" { - req.ConfigID = "preview_" + time.Now().Format("20060102.150405") - } - if req.ConfigVersion == "" { - req.ConfigVersion = time.Now().Format("20060102.150405") - } - resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw) - if err != nil { - return nil, err - } - metadata := map[string]any{ - "config_id": req.ConfigID, - "config_version": req.ConfigVersion, - "rendered_at": time.Now().Format(time.RFC3339), - "rendered_by": "managerd", - } - if strings.TrimSpace(req.DeviceID) != "" { - metadata["device_id"] = strings.TrimSpace(req.DeviceID) - } - doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, metadata) - if err != nil { - return nil, err - } - body, err := marshalConfigJSON(doc) - if err != nil { - return nil, err - } - renderedMetadata, _ := doc["metadata"].(map[string]any) - sum := sha256.Sum256(body) - return &ConfigPreviewResult{ - Request: req, - Root: previewRenderRoot(templatePath, profilePath), - Sha256: hex.EncodeToString(sum[:]), - Size: len(body), - Metadata: renderedMetadata, - JSON: string(body), - }, nil + if err := validateConfigName(req.Template); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) + } + if strings.TrimSpace(req.Profile) != "" { + if err := validateConfigName(req.Profile); err != nil { + return nil, fmt.Errorf("invalid profile: %w", err) + } + } + for _, overlay := range req.Overlays { + if err := validateConfigName(overlay); err != nil { + return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err) + } + } + if req.ConfigID == "" { + req.ConfigID = "preview_" + time.Now().Format("20060102.150405") + } + if req.ConfigVersion == "" { + req.ConfigVersion = time.Now().Format("20060102.150405") + } + resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw) + if err != nil { + return nil, err + } + metadata := map[string]any{ + "config_id": req.ConfigID, + "config_version": req.ConfigVersion, + "rendered_at": time.Now().Format(time.RFC3339), + "rendered_by": "managerd", + } + if strings.TrimSpace(req.DeviceID) != "" { + metadata["device_id"] = strings.TrimSpace(req.DeviceID) + } + doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, metadata) + if err != nil { + return nil, err + } + body, err := marshalConfigJSON(doc) + if err != nil { + return nil, err + } + renderedMetadata, _ := doc["metadata"].(map[string]any) + sum := sha256.Sum256(body) + return &ConfigPreviewResult{ + Request: req, + Root: previewRenderRoot(templatePath, profilePath), + Sha256: hex.EncodeToString(sum[:]), + Size: len(body), + Metadata: renderedMetadata, + JSON: string(body), + }, nil } func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) { - if raw == nil { - return map[string]any{}, nil - } - body, err := json.Marshal(raw) - if err != nil { - return nil, err - } - var clone map[string]any - if err := json.Unmarshal(body, &clone); err != nil { - return nil, err - } - instances, _ := clone["instances"].([]any) - for _, item := range instances { - instanceMap, _ := item.(map[string]any) - paramsMap, _ := instanceMap["params"].(map[string]any) - if paramsMap == nil { - paramsMap = map[string]any{} - instanceMap["params"] = paramsMap - } - inputBindings, _ := instanceMap["input_bindings"].(map[string]any) - if inputBindings == nil { - inputBindings = map[string]any{} - instanceMap["input_bindings"] = inputBindings - } - videoSourceRef := bindingField(inputBindings, "video_input_main", "video_source_ref") - if videoSourceRef != "" { - asset, err := s.GetVideoSource(videoSourceRef) - if err != nil { - return nil, fmt.Errorf("load video_source_ref %q: %w", videoSourceRef, err) - } - entry, _ := inputBindings["video_input_main"].(map[string]any) - if entry == nil { - entry = map[string]any{} - } - entry["video_source_ref"] = videoSourceRef - entry["resolved"] = map[string]any{ - "url": asset.Config.URL, - "resolution": asset.Config.Resolution, - "frame_size": asset.Config.FrameSize, - "fps": asset.Config.FPS, - "video_format": asset.Config.VideoFormat, - } - inputBindings["video_input_main"] = entry - } + if raw == nil { + return map[string]any{}, nil + } + body, err := json.Marshal(raw) + if err != nil { + return nil, err + } + var clone map[string]any + if err := json.Unmarshal(body, &clone); err != nil { + return nil, err + } + instances, _ := clone["instances"].([]any) + for _, item := range instances { + instanceMap, _ := item.(map[string]any) + paramsMap, _ := instanceMap["params"].(map[string]any) + if paramsMap == nil { + paramsMap = map[string]any{} + instanceMap["params"] = paramsMap + } + inputBindings, _ := instanceMap["input_bindings"].(map[string]any) + if inputBindings == nil { + inputBindings = map[string]any{} + instanceMap["input_bindings"] = inputBindings + } + videoSourceRef := bindingField(inputBindings, "video_input_main", "video_source_ref") + if videoSourceRef != "" { + asset, err := s.GetVideoSource(videoSourceRef) + if err != nil { + return nil, fmt.Errorf("load video_source_ref %q: %w", videoSourceRef, err) + } + entry, _ := inputBindings["video_input_main"].(map[string]any) + if entry == nil { + entry = map[string]any{} + } + entry["video_source_ref"] = videoSourceRef + entry["resolved"] = map[string]any{ + "url": asset.Config.URL, + "resolution": asset.Config.Resolution, + "frame_size": asset.Config.FrameSize, + "fps": asset.Config.FPS, + "video_format": asset.Config.VideoFormat, + } + inputBindings["video_input_main"] = entry + } - serviceBindings, _ := instanceMap["service_bindings"].(map[string]any) - if serviceBindings == nil { - serviceBindings = map[string]any{} - instanceMap["service_bindings"] = serviceBindings - } - for _, binding := range []struct { - slot string - expected string - }{ - {slot: "object_storage_main", expected: "object_storage"}, - {slot: "token_service_main", expected: "token_service"}, - {slot: "alarm_service_main", expected: "alarm_service"}, - } { - serviceRef := bindingField(serviceBindings, binding.slot, "service_ref") - if serviceRef == "" { - continue - } - asset, err := s.GetIntegrationService(serviceRef) - if err != nil { - return nil, fmt.Errorf("load service_ref %q: %w", serviceRef, err) - } - if strings.TrimSpace(asset.Type) != binding.expected { - return nil, fmt.Errorf("service_ref %q has type %q, expected %q", serviceRef, asset.Type, binding.expected) - } - entry, _ := serviceBindings[binding.slot].(map[string]any) - if entry == nil { - entry = map[string]any{} - } - entry["service_ref"] = serviceRef - entry["resolved"] = resolvedServiceBinding(asset) - serviceBindings[binding.slot] = entry - } - } - return clone, nil + serviceBindings, _ := instanceMap["service_bindings"].(map[string]any) + if serviceBindings == nil { + serviceBindings = map[string]any{} + instanceMap["service_bindings"] = serviceBindings + } + for _, binding := range []struct { + slot string + expected string + }{ + {slot: "object_storage_main", expected: "object_storage"}, + {slot: "token_service_main", expected: "token_service"}, + {slot: "alarm_service_main", expected: "alarm_service"}, + } { + serviceRef := bindingField(serviceBindings, binding.slot, "service_ref") + if serviceRef == "" { + continue + } + asset, err := s.GetIntegrationService(serviceRef) + if err != nil { + return nil, fmt.Errorf("load service_ref %q: %w", serviceRef, err) + } + if strings.TrimSpace(asset.Type) != binding.expected { + return nil, fmt.Errorf("service_ref %q has type %q, expected %q", serviceRef, asset.Type, binding.expected) + } + entry, _ := serviceBindings[binding.slot].(map[string]any) + if entry == nil { + entry = map[string]any{} + } + entry["service_ref"] = serviceRef + entry["resolved"] = resolvedServiceBinding(asset) + serviceBindings[binding.slot] = entry + } + } + return clone, nil } func bindingField(bindings map[string]any, slot string, field string) string { - entry, _ := bindings[slot].(map[string]any) - return stringValue(entry[field]) + entry, _ := bindings[slot].(map[string]any) + return stringValue(entry[field]) } func resolvedServiceBinding(asset *ConfigIntegrationServiceAsset) map[string]any { - if asset == nil { - return nil - } - switch asset.Type { - case "object_storage": - if asset.ObjectStorage == nil { - return nil - } - return map[string]any{ - "endpoint": asset.ObjectStorage.Endpoint, - "bucket": asset.ObjectStorage.Bucket, - "access_key": asset.ObjectStorage.AccessKey, - "secret_key": asset.ObjectStorage.SecretKey, - } - case "token_service": - if asset.TokenService == nil { - return nil - } - return map[string]any{ - "get_token_url": asset.TokenService.GetTokenURL, - "username": asset.TokenService.Username, - "password": asset.TokenService.Password, - "tenant_code": asset.TokenService.TenantCode, - } - case "alarm_service": - if asset.AlarmService == nil { - return nil - } - return map[string]any{ - "put_message_url": asset.AlarmService.PutMessageURL, - "username": asset.AlarmService.Username, - "password": asset.AlarmService.Password, - "tenant_code": asset.AlarmService.TenantCode, - } - default: - return nil - } + if asset == nil { + return nil + } + switch asset.Type { + case "object_storage": + if asset.ObjectStorage == nil { + return nil + } + return map[string]any{ + "endpoint": asset.ObjectStorage.Endpoint, + "bucket": asset.ObjectStorage.Bucket, + "access_key": asset.ObjectStorage.AccessKey, + "secret_key": asset.ObjectStorage.SecretKey, + } + case "token_service": + if asset.TokenService == nil { + return nil + } + return map[string]any{ + "get_token_url": asset.TokenService.GetTokenURL, + "username": asset.TokenService.Username, + "password": asset.TokenService.Password, + "tenant_code": asset.TokenService.TenantCode, + } + case "alarm_service": + if asset.AlarmService == nil { + return nil + } + return map[string]any{ + "put_message_url": asset.AlarmService.PutMessageURL, + "username": asset.AlarmService.Username, + "password": asset.AlarmService.Password, + "tenant_code": asset.AlarmService.TenantCode, + } + default: + return nil + } } func previewRenderRoot(templatePath string, profilePath string) string { - if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") { - return "SQLite" - } - if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." { - return dir - } - return "managerd" + if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") { + return "SQLite" + } + if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." { + return dir + } + return "managerd" } func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) { - body, err := marshalConfigJSON(raw) - if err != nil { - return "", err - } - tempFile, err := os.CreateTemp("", pattern) - if err != nil { - return "", err - } - path := tempFile.Name() - if _, err := tempFile.Write(body); err != nil { - _ = tempFile.Close() - _ = os.Remove(path) - return "", err - } - _ = tempFile.Close() - return path, nil + body, err := marshalConfigJSON(raw) + if err != nil { + return "", err + } + tempFile, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + path := tempFile.Name() + if _, err := tempFile.Write(body); err != nil { + _ = tempFile.Close() + _ = os.Remove(path) + return "", err + } + _ = tempFile.Close() + return path, nil } func setAnyString(m map[string]any, key string, value string) { - if strings.TrimSpace(value) != "" { - m[key] = strings.TrimSpace(value) - } + if strings.TrimSpace(value) != "" { + m[key] = strings.TrimSpace(value) + } } func (s *ConfigPreviewService) mediaRepoRoot() string { - if s.cfg == nil { - return "" - } - if strings.TrimSpace(s.cfg.MediaRepoPath) == "" { - return "" - } - return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath)) + if s.cfg == nil { + return "" + } + if strings.TrimSpace(s.cfg.MediaRepoPath) == "" { + return "" + } + return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath)) } func listConfigSources(dir string) ([]ConfigSource, error) { - files, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return []ConfigSource{}, nil - } - return nil, err - } - out := make([]ConfigSource, 0) - for _, file := range files { - if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" { - continue - } - name := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())) - out = append(out, ConfigSource{Name: name, Path: filepath.Join(dir, file.Name())}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out, nil + files, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return []ConfigSource{}, nil + } + return nil, err + } + out := make([]ConfigSource, 0) + for _, file := range files { + if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" { + continue + } + name := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())) + out = append(out, ConfigSource{Name: name, Path: filepath.Join(dir, file.Name())}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil } func validateConfigName(name string) error { - if !safeConfigName.MatchString(strings.TrimSpace(name)) { - return fmt.Errorf("must contain only letters, numbers, dot, underscore, or dash") - } - if strings.Contains(name, "..") { - return fmt.Errorf("must not contain '..'") - } - return nil + if !safeConfigName.MatchString(strings.TrimSpace(name)) { + return fmt.Errorf("must contain only letters, numbers, dot, underscore, or dash") + } + if strings.Contains(name, "..") { + return fmt.Errorf("must not contain '..'") + } + return nil } func repoAssetPath(kind string, name string) string { - return "sqlite:" + kind + "/" + strings.TrimSpace(name) + return "sqlite:" + kind + "/" + strings.TrimSpace(name) } func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportResult, error) { - if s == nil || s.assets == nil { - return nil, fmt.Errorf("asset repository is not configured") - } - root := s.mediaRepoRoot() - if root == "" { - return nil, fmt.Errorf("legacy import source path is not configured") - } - result := &ConfigAssetImportResult{Root: root} - for _, item := range []struct { - kind string - inc *int - }{ - {kind: "templates", inc: &result.Templates}, - {kind: "profiles", inc: &result.Profiles}, - {kind: "overlays", inc: &result.Overlays}, - } { - sources, err := listConfigSources(filepath.Join(root, "configs", item.kind)) - if err != nil { - return nil, err - } - for _, source := range sources { - body, err := os.ReadFile(source.Path) - if err != nil { - return nil, err - } - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - name := firstString(raw["name"], source.Name) - description := stringValue(raw["description"]) - switch item.kind { - case "templates": - if raw == nil { - raw = map[string]any{} - } - if isStandardTemplateName(name) && strings.TrimSpace(stringValue(raw["source"])) == "" { - raw["source"] = "standard" - body, err = marshalConfigJSON(raw) - if err != nil { - return nil, err - } - } - if err := s.assets.SaveTemplate(name, description, string(body)); err != nil { - return nil, err - } - case "profiles": - if err := s.assets.SaveProfile(name, profileRawTemplateName(raw), stringValue(raw["business_name"]), description, string(body)); err != nil { - return nil, err - } - case "overlays": - if err := s.assets.SaveOverlay(name, description, string(body)); err != nil { - return nil, err - } - } - *item.inc = *item.inc + 1 - } - } - return result, nil + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + root := s.mediaRepoRoot() + if root == "" { + return nil, fmt.Errorf("legacy import source path is not configured") + } + result := &ConfigAssetImportResult{Root: root} + for _, item := range []struct { + kind string + inc *int + }{ + {kind: "templates", inc: &result.Templates}, + {kind: "profiles", inc: &result.Profiles}, + {kind: "overlays", inc: &result.Overlays}, + } { + sources, err := listConfigSources(filepath.Join(root, "configs", item.kind)) + if err != nil { + return nil, err + } + for _, source := range sources { + body, err := os.ReadFile(source.Path) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + name := firstString(raw["name"], source.Name) + description := stringValue(raw["description"]) + switch item.kind { + case "templates": + if raw == nil { + raw = map[string]any{} + } + if isStandardTemplateName(name) && strings.TrimSpace(stringValue(raw["source"])) == "" { + raw["source"] = "standard" + body, err = marshalConfigJSON(raw) + if err != nil { + return nil, err + } + } + if err := s.assets.SaveTemplate(name, description, string(body)); err != nil { + return nil, err + } + case "profiles": + if err := s.assets.SaveProfile(name, profileRawTemplateName(raw), stringValue(raw["business_name"]), description, string(body)); err != nil { + return nil, err + } + case "overlays": + if err := s.assets.SaveOverlay(name, description, string(body)); err != nil { + return nil, err + } + } + *item.inc = *item.inc + 1 + } + } + return result, nil } func (s *ConfigPreviewService) ExportAssetJSON(kind string, name string) ([]byte, string, error) { - if err := validateConfigName(name); err != nil { - return nil, "", err - } - if s == nil || s.assets == nil { - return nil, "", fmt.Errorf("asset repository is not configured") - } - if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil { - return body, name + ".json", err - } - return nil, "", os.ErrNotExist + if err := validateConfigName(name); err != nil { + return nil, "", err + } + if s == nil || s.assets == nil { + return nil, "", fmt.Errorf("asset repository is not configured") + } + if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil { + return body, name + ".json", err + } + return nil, "", os.ErrNotExist } func (s *ConfigPreviewService) exportRepoAssetJSON(kind string, name string) ([]byte, bool, error) { - var ( - record *storage.AssetRecord - err error - ) - switch kind { - case "templates": - record, err = s.assets.GetTemplate(name) - case "profiles": - record, err = s.assets.GetProfile(name) - case "overlays": - record, err = s.assets.GetOverlay(name) - default: - return nil, true, fmt.Errorf("unsupported asset kind: %s", kind) - } - if err != nil { - return nil, true, err - } - if record == nil { - return nil, false, nil - } - return []byte(record.BodyJSON), true, nil + var ( + record *storage.AssetRecord + err error + ) + switch kind { + case "templates": + record, err = s.assets.GetTemplate(name) + case "profiles": + record, err = s.assets.GetProfile(name) + case "overlays": + record, err = s.assets.GetOverlay(name) + default: + return nil, true, fmt.Errorf("unsupported asset kind: %s", kind) + } + if err != nil { + return nil, true, err + } + if record == nil { + return nil, false, nil + } + return []byte(record.BodyJSON), true, nil } func profileRawTemplateName(raw map[string]any) string { - instances, _ := raw["instances"].([]any) - for _, item := range instances { - instanceMap, _ := item.(map[string]any) - if v := stringValue(instanceMap["template"]); v != "" { - return v - } - } - return stringValue(raw["primary_template_name"]) + instances, _ := raw["instances"].([]any) + for _, item := range instances { + instanceMap, _ := item.(map[string]any) + if v := stringValue(instanceMap["template"]); v != "" { + return v + } + } + return stringValue(raw["primary_template_name"]) } diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index 999f301..2fb396c 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -1,165 +1,165 @@ package service import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/storage" ) func mustImportAssetsFromMediaRepo(t *testing.T, svc *ConfigPreviewService) { - t.Helper() - if _, err := svc.ImportAssetsFromMediaRepo(); err != nil { - t.Fatalf("ImportAssetsFromMediaRepo: %v", err) - } + t.Helper() + if _, err := svc.ImportAssetsFromMediaRepo(); err != nil { + t.Fatalf("ImportAssetsFromMediaRepo: %v", err) + } } func TestConfigPreviewServiceListsSources(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) - mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) - mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsFromMediaRepo(t, svc) - sources, err := svc.ListSources() - if err != nil { - t.Fatalf("ListSources: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) + sources, err := svc.ListSources() + if err != nil { + t.Fatalf("ListSources: %v", err) + } - if sources.Root != "SQLite" { - t.Fatalf("expected root %q, got %q", "SQLite", sources.Root) - } - if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" { - t.Fatalf("unexpected templates: %v", got) - } - if got := sourceNames(sources.Profiles); strings.Join(got, ",") != "local_3588_test" { - t.Fatalf("unexpected profiles: %v", got) - } - if got := sourceNames(sources.Overlays); strings.Join(got, ",") != "face_debug" { - t.Fatalf("unexpected overlays: %v", got) - } + if sources.Root != "SQLite" { + t.Fatalf("expected root %q, got %q", "SQLite", sources.Root) + } + if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" { + t.Fatalf("unexpected templates: %v", got) + } + if got := sourceNames(sources.Profiles); strings.Join(got, ",") != "local_3588_test" { + t.Fatalf("unexpected profiles: %v", got) + } + if got := sourceNames(sources.Overlays); strings.Join(got, ",") != "face_debug" { + t.Fatalf("unexpected overlays: %v", got) + } } func TestConfigPreviewServiceListSourcesAllowsEmptyConfigsDir(t *testing.T) { - root := t.TempDir() - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + root := t.TempDir() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.SaveProfile("line_a", "helmet", "Line A", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"helmet","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("line_a", "helmet", "Line A", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"helmet","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - sources, err := svc.ListSources() - if err != nil { - t.Fatalf("ListSources: %v", err) - } - if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { - t.Fatalf("unexpected templates: %#v", got) - } - if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "line_a" { - t.Fatalf("unexpected profiles: %#v", got) - } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + sources, err := svc.ListSources() + if err != nil { + t.Fatalf("ListSources: %v", err) + } + if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { + t.Fatalf("unexpected templates: %#v", got) + } + if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "line_a" { + t.Fatalf("unexpected profiles: %#v", got) + } } func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) { - root := t.TempDir() - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) + root := t.TempDir() + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}) - _, err := svc.Render(ConfigPreviewRequest{ - Template: "../secret", - Profile: "local_3588_test", - ConfigID: "preview", - ConfigVersion: "v1", - }) - if err == nil { - t.Fatal("expected unsafe template name to be rejected") - } + _, err := svc.Render(ConfigPreviewRequest{ + Template: "../secret", + Profile: "local_3588_test", + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err == nil { + t.Fatal("expected unsafe template name to be rejected") + } } func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"helmet template","template":{"nodes":[],"edges":[]}}`) - mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) - mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","description":"overlay","instance_overrides":{"cam1":{"override":{}}}}`) + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"helmet template","template":{"nodes":[],"edges":[]}}`) + mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) + mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","description":"overlay","instance_overrides":{"cam1":{"override":{}}}}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - result, err := svc.ImportAssetsFromMediaRepo() - if err != nil { - t.Fatalf("ImportAssetsFromMediaRepo: %v", err) - } - if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { - t.Fatalf("unexpected import result: %#v", result) - } + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + result, err := svc.ImportAssetsFromMediaRepo() + if err != nil { + t.Fatalf("ImportAssetsFromMediaRepo: %v", err) + } + if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { + t.Fatalf("unexpected import result: %#v", result) + } - sources, err := svc.ListSources() - if err != nil { - t.Fatalf("ListSources: %v", err) - } - if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "std_face_recognition_stream" { - t.Fatalf("unexpected templates after import: %#v", got) - } - if record, err := repo.GetTemplate("std_face_recognition_stream"); err != nil { - t.Fatalf("GetTemplate: %v", err) - } else if record == nil { - t.Fatal("expected imported standard template") - } + sources, err := svc.ListSources() + if err != nil { + t.Fatalf("ListSources: %v", err) + } + if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "std_face_recognition_stream" { + t.Fatalf("unexpected templates after import: %#v", got) + } + if record, err := repo.GetTemplate("std_face_recognition_stream"); err != nil { + t.Fatalf("GetTemplate: %v", err) + } else if record == nil { + t.Fatal("expected imported standard template") + } } func TestConfigPreviewServiceExportsAssetJSONFromSQLite(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n" - if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } + repo := storage.NewAssetsRepo(store.DB()) + const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n" + if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } - svc := NewConfigPreviewService(&config.Config{}, repo) - body, filename, err := svc.ExportAssetJSON("templates", "helmet") - if err != nil { - t.Fatalf("ExportAssetJSON: %v", err) - } - if filename != "helmet.json" { - t.Fatalf("unexpected export filename: %q", filename) - } - if string(body) != raw { - t.Fatalf("unexpected export body: %s", string(body)) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + body, filename, err := svc.ExportAssetJSON("templates", "helmet") + if err != nil { + t.Fatalf("ExportAssetJSON: %v", err) + } + if filename != "helmet.json" { + t.Fatalf("unexpected export filename: %q", filename) + } + if string(body) != raw { + t.Fatalf("unexpected export body: %s", string(body)) + } } func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name":"std_workshop_face_recognition_shoe_alarm", "template":{ "nodes":[ @@ -170,80 +170,80 @@ func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testin } }`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveVideoSource( - "gate_cam_01", - "rtsp", - "东门入口", - "东门主入口摄像头", - `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, - ); err != nil { - t.Fatalf("SaveVideoSource: %v", err) - } - saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) - saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) + saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsFromMediaRepo(t, svc) - editor := ConfigProfileEditor{ - Name: "line_a", - Instances: []ConfigProfileInstanceEditor{{ - Name: "cam1", - Template: "std_workshop_face_recognition_shoe_alarm", - VideoSourceRef: "gate_cam_01", - PublishHLSPath: "./web/hls/cam1/index.m3u8", - PublishRTSPPort: "8555", - PublishRTSPPath: "/live/cam1", - ChannelNo: "cam1", - ServiceBindings: map[string]ServiceBindingEditor{ - "object_storage_main": {ServiceRef: "minio_main"}, - "token_service_main": {ServiceRef: "token_main"}, - "alarm_service_main": {ServiceRef: "alarm_main"}, - }, - }}, - } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) + editor := ConfigProfileEditor{ + Name: "line_a", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam1", + Template: "std_workshop_face_recognition_shoe_alarm", + VideoSourceRef: "gate_cam_01", + PublishHLSPath: "./web/hls/cam1/index.m3u8", + PublishRTSPPort: "8555", + PublishRTSPPath: "/live/cam1", + ChannelNo: "cam1", + ServiceBindings: map[string]ServiceBindingEditor{ + "object_storage_main": {ServiceRef: "minio_main"}, + "token_service_main": {ServiceRef: "token_main"}, + "alarm_service_main": {ServiceRef: "alarm_main"}, + }, + }}, + } - result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{ - Template: "std_workshop_face_recognition_shoe_alarm", - ConfigID: "preview", - ConfigVersion: "v1", - }) - if err != nil { - t.Fatalf("RenderProfileEditor: %v", err) - } + result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{ + Template: "std_workshop_face_recognition_shoe_alarm", + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err != nil { + t.Fatalf("RenderProfileEditor: %v", err) + } - var doc map[string]any - if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { - t.Fatalf("unmarshal render result: %v", err) - } - templates, _ := doc["templates"].(map[string]any) - renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any) - nodes, _ := renderedTemplate["nodes"].([]any) - inputNode, _ := nodes[0].(map[string]any) - if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" { - t.Fatalf("expected expanded input url, got %#v", inputNode) - } - publishNode, _ := nodes[1].(map[string]any) - outputs, _ := publishNode["outputs"].([]any) - output, _ := outputs[0].(map[string]any) - if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" { - t.Fatalf("expected expanded output path, got %#v", output) - } - metadata, _ := doc["metadata"].(map[string]any) - if got := stringValue(metadata["rendered_by"]); got != "managerd" { - t.Fatalf("expected managerd renderer metadata, got %#v", metadata) - } + var doc map[string]any + if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { + t.Fatalf("unmarshal render result: %v", err) + } + templates, _ := doc["templates"].(map[string]any) + renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any) + nodes, _ := renderedTemplate["nodes"].([]any) + inputNode, _ := nodes[0].(map[string]any) + if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" { + t.Fatalf("expected expanded input url, got %#v", inputNode) + } + publishNode, _ := nodes[1].(map[string]any) + outputs, _ := publishNode["outputs"].([]any) + output, _ := outputs[0].(map[string]any) + if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" { + t.Fatalf("expected expanded output path, got %#v", output) + } + metadata, _ := doc["metadata"].(map[string]any) + if got := stringValue(metadata["rendered_by"]); got != "managerd" { + t.Fatalf("expected managerd renderer metadata, got %#v", metadata) + } } func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name":"std_workshop_face_recognition_shoe_alarm", "source":"standard", "slots":{ @@ -260,63 +260,63 @@ func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot } }`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveVideoSource( - "gate_cam_01", - "rtsp", - "东门入口", - "东门主入口摄像头", - `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, - ); err != nil { - t.Fatalf("SaveVideoSource: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsFromMediaRepo(t, svc) - editor := ConfigProfileEditor{ - Name: "line_a", - Instances: []ConfigProfileInstanceEditor{{ - Name: "cam1", - Template: "std_workshop_face_recognition_shoe_alarm", - VideoSourceRef: "gate_cam_01", - PublishRTSPPort: "8555", - ChannelNo: "cam1", - }}, - } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) + editor := ConfigProfileEditor{ + Name: "line_a", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam1", + Template: "std_workshop_face_recognition_shoe_alarm", + VideoSourceRef: "gate_cam_01", + PublishRTSPPort: "8555", + ChannelNo: "cam1", + }}, + } - result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{ - Template: "std_workshop_face_recognition_shoe_alarm", - ConfigID: "preview", - ConfigVersion: "v1", - }) - if err != nil { - t.Fatalf("RenderProfileEditor: %v", err) - } + result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{ + Template: "std_workshop_face_recognition_shoe_alarm", + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err != nil { + t.Fatalf("RenderProfileEditor: %v", err) + } - var doc map[string]any - if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { - t.Fatalf("unmarshal render result: %v", err) - } - templates, _ := doc["templates"].(map[string]any) - renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any) - nodes, _ := renderedTemplate["nodes"].([]any) - alarmNode, _ := nodes[1].(map[string]any) - outputs, _ := alarmNode["outputs"].([]any) - output, _ := outputs[0].(map[string]any) - externalAPI, _ := output["external_api"].(map[string]any) - if got := stringValue(externalAPI["getTokenUrl"]); got != "" { - t.Fatalf("expected empty getTokenUrl for unbound optional slot, got %#v", externalAPI) - } + var doc map[string]any + if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { + t.Fatalf("unmarshal render result: %v", err) + } + templates, _ := doc["templates"].(map[string]any) + renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any) + nodes, _ := renderedTemplate["nodes"].([]any) + alarmNode, _ := nodes[1].(map[string]any) + outputs, _ := alarmNode["outputs"].([]any) + output, _ := outputs[0].(map[string]any) + externalAPI, _ := output["external_api"].(map[string]any) + if got := stringValue(externalAPI["getTokenUrl"]); got != "" { + t.Fatalf("expected empty getTokenUrl for unbound optional slot, got %#v", externalAPI) + } } func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{ + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{ "name":"std_service_test_stream", "template":{ "nodes":[ @@ -327,13 +327,13 @@ func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) { } }`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{ + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{ "name":"line_a", "business_name":"Line A", "instances":[ @@ -345,64 +345,64 @@ func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) { } ] }`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil { - t.Fatalf("SaveOverlay: %v", err) - } - if err := repo.SaveVideoSource( - "gate_cam_01", - "rtsp", - "东门入口", - "东门主入口摄像头", - `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, - ); err != nil { - t.Fatalf("SaveVideoSource: %v", err) - } + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil { + t.Fatalf("SaveOverlay: %v", err) + } + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsFromMediaRepo(t, svc) - result, err := svc.Render(ConfigPreviewRequest{ - Template: "std_service_test_stream", - Profile: "line_a", - Overlays: []string{"night_relaxed"}, - ConfigID: "preview", - ConfigVersion: "v1", - }) - if err != nil { - t.Fatalf("Render: %v", err) - } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) + result, err := svc.Render(ConfigPreviewRequest{ + Template: "std_service_test_stream", + Profile: "line_a", + Overlays: []string{"night_relaxed"}, + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err != nil { + t.Fatalf("Render: %v", err) + } - var doc map[string]any - if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { - t.Fatalf("unmarshal render result: %v", err) - } - metadata, _ := doc["metadata"].(map[string]any) - if got := stringValue(metadata["profile"]); got != "line_a" { - t.Fatalf("expected sqlite profile metadata, got %#v", metadata) - } - names, _ := metadata["overlays"].([]any) - if len(names) != 1 || stringValue(names[0]) != "night_relaxed" { - t.Fatalf("expected sqlite overlay metadata, got %#v", metadata["overlays"]) - } - templates, _ := doc["templates"].(map[string]any) - renderedTemplate, _ := templates["std_service_test_stream__cam1"].(map[string]any) - nodes, _ := renderedTemplate["nodes"].([]any) - inputNode, _ := nodes[0].(map[string]any) - if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" { - t.Fatalf("expected expanded input url, got %#v", inputNode) - } - instances, _ := doc["instances"].([]any) - instance, _ := instances[0].(map[string]any) - params, _ := instance["params"].(map[string]any) - if got := boolValue(params["debug"], false); !got { - t.Fatalf("expected overlay params to merge into runtime instance, got %#v", instance) - } + var doc map[string]any + if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { + t.Fatalf("unmarshal render result: %v", err) + } + metadata, _ := doc["metadata"].(map[string]any) + if got := stringValue(metadata["profile"]); got != "line_a" { + t.Fatalf("expected sqlite profile metadata, got %#v", metadata) + } + names, _ := metadata["overlays"].([]any) + if len(names) != 1 || stringValue(names[0]) != "night_relaxed" { + t.Fatalf("expected sqlite overlay metadata, got %#v", metadata["overlays"]) + } + templates, _ := doc["templates"].(map[string]any) + renderedTemplate, _ := templates["std_service_test_stream__cam1"].(map[string]any) + nodes, _ := renderedTemplate["nodes"].([]any) + inputNode, _ := nodes[0].(map[string]any) + if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" { + t.Fatalf("expected expanded input url, got %#v", inputNode) + } + instances, _ := doc["instances"].([]any) + instance, _ := instances[0].(map[string]any) + params, _ := instance["params"].(map[string]any) + if got := boolValue(params["debug"], false); !got { + t.Fatalf("expected overlay params to merge into runtime instance, got %#v", instance) + } } func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{ + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{ "name":"std_service_test_stream", "template":{ "nodes":[ @@ -411,13 +411,13 @@ func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testi "edges":[] } }`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{ + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{ "name":"line_a", "business_name":"Line A", "overlays":["night_relaxed"], @@ -429,144 +429,144 @@ func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testi } ] }`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil { - t.Fatalf("SaveOverlay: %v", err) - } - if err := repo.SaveVideoSource( - "gate_cam_01", - "rtsp", - "东门入口", - "东门主入口摄像头", - `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, - ); err != nil { - t.Fatalf("SaveVideoSource: %v", err) - } + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil { + t.Fatalf("SaveOverlay: %v", err) + } + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } - svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsFromMediaRepo(t, svc) - result, err := svc.Render(ConfigPreviewRequest{ - Template: "std_service_test_stream", - Profile: "line_a", - ConfigID: "preview", - ConfigVersion: "v1", - }) - if err != nil { - t.Fatalf("Render: %v", err) - } + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsFromMediaRepo(t, svc) + result, err := svc.Render(ConfigPreviewRequest{ + Template: "std_service_test_stream", + Profile: "line_a", + ConfigID: "preview", + ConfigVersion: "v1", + }) + if err != nil { + t.Fatalf("Render: %v", err) + } - var doc map[string]any - if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { - t.Fatalf("unmarshal render result: %v", err) - } - metadata, _ := doc["metadata"].(map[string]any) - names, _ := metadata["overlays"].([]any) - if len(names) != 1 || stringValue(names[0]) != "night_relaxed" { - t.Fatalf("expected profile overlay metadata, got %#v", metadata["overlays"]) - } - instances, _ := doc["instances"].([]any) - instance, _ := instances[0].(map[string]any) - params, _ := instance["params"].(map[string]any) - if got := boolValue(params["debug"], false); !got { - t.Fatalf("expected profile overlay params to merge into runtime instance, got %#v", instance) - } + var doc map[string]any + if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil { + t.Fatalf("unmarshal render result: %v", err) + } + metadata, _ := doc["metadata"].(map[string]any) + names, _ := metadata["overlays"].([]any) + if len(names) != 1 || stringValue(names[0]) != "night_relaxed" { + t.Fatalf("expected profile overlay metadata, got %#v", metadata["overlays"]) + } + instances, _ := doc["instances"].([]any) + instance, _ := instances[0].(map[string]any) + params, _ := instance["params"].(map[string]any) + if got := boolValue(params["debug"], false); !got { + t.Fatalf("expected profile overlay params to merge into runtime instance, got %#v", instance) + } } func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) { - svc := NewConfigPreviewService(&config.Config{}) - err := svc.SaveProfileEditor(ConfigProfileEditor{ - Name: "line_a", - Instances: []ConfigProfileInstanceEditor{{ - Name: "cam1", - Template: "helmet", - VideoSourceRef: "gate_cam_01", - }}, - }) - if err == nil || !strings.Contains(err.Error(), "asset repository is not configured") { - t.Fatalf("expected asset repository error, got %v", err) - } + svc := NewConfigPreviewService(&config.Config{}) + err := svc.SaveProfileEditor(ConfigProfileEditor{ + Name: "line_a", + Instances: []ConfigProfileInstanceEditor{{ + Name: "cam1", + Template: "helmet", + VideoSourceRef: "gate_cam_01", + }}, + }) + if err == nil || !strings.Contains(err.Error(), "asset repository is not configured") { + t.Fatalf("expected asset repository error, got %v", err) + } } func TestConfigPreviewServiceResolveSceneBindings(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveVideoSource( - "gate_cam_01", - "rtsp", - "东门入口", - "东门主入口摄像头", - `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, - ); err != nil { - t.Fatalf("SaveVideoSource: %v", err) - } - saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } + saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - svc := NewConfigPreviewService(&config.Config{}, repo) - resolved, err := svc.resolveSceneBindings(map[string]any{ - "name": "line_a", - "instances": []any{ - map[string]any{ - "name": "cam1", - "template": "std_workshop_face_recognition_shoe_alarm", - "input_bindings": map[string]any{ - "video_input_main": map[string]any{ - "video_source_ref": "gate_cam_01", - }, - }, - "service_bindings": map[string]any{ - "object_storage_main": map[string]any{ - "service_ref": "minio_main", - }, - }, - }, - }, - }) - if err != nil { - t.Fatalf("resolveSceneBindings: %v", err) - } - instances, _ := resolved["instances"].([]any) - instanceMap, _ := instances[0].(map[string]any) - inputBindings, _ := instanceMap["input_bindings"].(map[string]any) - videoInput, _ := inputBindings["video_input_main"].(map[string]any) - resolvedInput, _ := videoInput["resolved"].(map[string]any) - if got := stringValue(resolvedInput["url"]); got != "rtsp://10.0.0.1/live" { - t.Fatalf("expected resolved input url, got %#v", resolvedInput) - } - serviceBindings, _ := instanceMap["service_bindings"].(map[string]any) - objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) - resolvedService, _ := objectStorage["resolved"].(map[string]any) - if got := stringValue(resolvedService["bucket"]); got != "myminio" { - t.Fatalf("expected resolved service binding, got %#v", resolvedService) - } + svc := NewConfigPreviewService(&config.Config{}, repo) + resolved, err := svc.resolveSceneBindings(map[string]any{ + "name": "line_a", + "instances": []any{ + map[string]any{ + "name": "cam1", + "template": "std_workshop_face_recognition_shoe_alarm", + "input_bindings": map[string]any{ + "video_input_main": map[string]any{ + "video_source_ref": "gate_cam_01", + }, + }, + "service_bindings": map[string]any{ + "object_storage_main": map[string]any{ + "service_ref": "minio_main", + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("resolveSceneBindings: %v", err) + } + instances, _ := resolved["instances"].([]any) + instanceMap, _ := instances[0].(map[string]any) + inputBindings, _ := instanceMap["input_bindings"].(map[string]any) + videoInput, _ := inputBindings["video_input_main"].(map[string]any) + resolvedInput, _ := videoInput["resolved"].(map[string]any) + if got := stringValue(resolvedInput["url"]); got != "rtsp://10.0.0.1/live" { + t.Fatalf("expected resolved input url, got %#v", resolvedInput) + } + serviceBindings, _ := instanceMap["service_bindings"].(map[string]any) + objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) + resolvedService, _ := objectStorage["resolved"].(map[string]any) + if got := stringValue(resolvedService["bucket"]); got != "myminio" { + t.Fatalf("expected resolved service binding, got %#v", resolvedService) + } } func saveIntegrationServiceForPreviewTest(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) { - t.Helper() - if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil { - t.Fatalf("SaveIntegrationService(%s): %v", name, err) - } + t.Helper() + if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil { + t.Fatalf("SaveIntegrationService(%s): %v", name, err) + } } func sourceNames(items []ConfigSource) []string { - out := make([]string, 0, len(items)) - for _, item := range items { - out = append(out, item.Name) - } - return out + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, item.Name) + } + return out } func mustWrite(t *testing.T, path string, body string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) - } - if err := os.WriteFile(path, []byte(body), 0644); err != nil { - t.Fatalf("write %s: %v", path, err) - } + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("write %s: %v", path, err) + } } diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go index 6df997a..b03c5cc 100644 --- a/internal/service/config_runtime_render.go +++ b/internal/service/config_runtime_render.go @@ -1,447 +1,447 @@ package service import ( - "encoding/json" - "fmt" - "path/filepath" - "regexp" - "strings" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strings" ) var slotTokenRE = regexp.MustCompile(`^\$\{slot:([A-Za-z0-9_.-]+)\.([A-Za-z0-9_.-]+)\}$`) func renderRuntimeConfig(templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, metadata map[string]any) (map[string]any, error) { - tplName, err := runtimeTemplateName(templateRaw, templatePath) - if err != nil { - return nil, err - } - instances, err := runtimeProfileInstances(profileRaw, tplName) - if err != nil { - return nil, err - } - instances, err = mergeRuntimeTemplateParams(instances, runtimeTemplateParams(templateRaw)) - if err != nil { - return nil, err - } + tplName, err := runtimeTemplateName(templateRaw, templatePath) + if err != nil { + return nil, err + } + instances, err := runtimeProfileInstances(profileRaw, tplName) + if err != nil { + return nil, err + } + instances, err = mergeRuntimeTemplateParams(instances, runtimeTemplateParams(templateRaw)) + if err != nil { + return nil, err + } - renderedTemplates := map[string]any{} - renderedInstances := make([]any, 0, len(instances)) - for _, instance := range instances { - boundName, boundTemplate, renderedInstance, err := renderRuntimeSceneInstance(templateRaw, templatePath, instance) - if err != nil { - return nil, err - } - renderedTemplates[boundName] = boundTemplate - renderedInstances = append(renderedInstances, renderedInstance) - } + renderedTemplates := map[string]any{} + renderedInstances := make([]any, 0, len(instances)) + for _, instance := range instances { + boundName, boundTemplate, renderedInstance, err := renderRuntimeSceneInstance(templateRaw, templatePath, instance) + if err != nil { + return nil, err + } + renderedTemplates[boundName] = boundTemplate + renderedInstances = append(renderedInstances, renderedInstance) + } - root := map[string]any{ - "templates": renderedTemplates, - "instances": renderedInstances, - } - for _, key := range []string{"global", "queue"} { - if value, ok := profileRaw[key]; ok { - root[key] = deepCopyAny(value) - } - } + root := map[string]any{ + "templates": renderedTemplates, + "instances": renderedInstances, + } + for _, key := range []string{"global", "queue"} { + if value, ok := profileRaw[key]; ok { + root[key] = deepCopyAny(value) + } + } - for _, overlay := range overlays { - var err error - root, err = applyRuntimeOverlay(root, overlay.Raw) - if err != nil { - return nil, err - } - } + for _, overlay := range overlays { + var err error + root, err = applyRuntimeOverlay(root, overlay.Raw) + if err != nil { + return nil, err + } + } - if metadata != nil { - root["metadata"] = buildRuntimeMetadata(tplName, templatePath, profileRaw, profilePath, overlays, root, metadata) - } - return root, nil + if metadata != nil { + root["metadata"] = buildRuntimeMetadata(tplName, templatePath, profileRaw, profilePath, overlays, root, metadata) + } + return root, nil } type runtimeOverlayInput struct { - Name string - Path string - Raw map[string]any + Name string + Path string + Raw map[string]any } func runtimeTemplateName(templateRaw map[string]any, templatePath string) (string, error) { - name := strings.TrimSpace(firstString(templateRaw["name"], strings.TrimSuffix(filepath.Base(templatePath), filepath.Ext(templatePath)))) - if name == "" { - return "", fmt.Errorf("%s: template name is empty", templatePath) - } - return name, nil + name := strings.TrimSpace(firstString(templateRaw["name"], strings.TrimSuffix(filepath.Base(templatePath), filepath.Ext(templatePath)))) + if name == "" { + return "", fmt.Errorf("%s: template name is empty", templatePath) + } + return name, nil } func runtimeTemplateBody(templateRaw map[string]any) (map[string]any, error) { - body := templateRaw - if nested, ok := templateRaw["template"].(map[string]any); ok { - body = nested - } - nodes, hasNodes := body["nodes"].([]any) - edges, hasEdges := body["edges"].([]any) - if !hasNodes || !hasEdges { - return nil, fmt.Errorf("template body must contain nodes[] and edges[]") - } - out := map[string]any{ - "nodes": deepCopyAny(nodes), - "edges": deepCopyAny(edges), - } - if executor, ok := body["executor"]; ok { - out["executor"] = deepCopyAny(executor) - } - return out, nil + body := templateRaw + if nested, ok := templateRaw["template"].(map[string]any); ok { + body = nested + } + nodes, hasNodes := body["nodes"].([]any) + edges, hasEdges := body["edges"].([]any) + if !hasNodes || !hasEdges { + return nil, fmt.Errorf("template body must contain nodes[] and edges[]") + } + out := map[string]any{ + "nodes": deepCopyAny(nodes), + "edges": deepCopyAny(edges), + } + if executor, ok := body["executor"]; ok { + out["executor"] = deepCopyAny(executor) + } + return out, nil } func runtimeTemplateParams(templateRaw map[string]any) map[string]any { - params, _ := templateRaw["params"].(map[string]any) - if params == nil { - return map[string]any{} - } - return cloneMap(params) + params, _ := templateRaw["params"].(map[string]any) + if params == nil { + return map[string]any{} + } + return cloneMap(params) } func runtimeProfileInstances(profileRaw map[string]any, tplName string) ([]map[string]any, error) { - if items, ok := profileRaw["instances"].([]any); ok { - out := make([]map[string]any, 0, len(items)) - for _, item := range items { - instance, _ := item.(map[string]any) - if instance == nil { - return nil, fmt.Errorf("profile.instances entries must be objects") - } - cloned := deepCopyMap(instance) - if strings.TrimSpace(stringValue(cloned["template"])) == "" { - cloned["template"] = tplName - } - out = append(out, cloned) - } - return out, nil - } - name := strings.TrimSpace(stringValue(profileRaw["name"])) - if name == "" { - return nil, fmt.Errorf("profile must contain name or instances[]") - } - instance := map[string]any{ - "name": name, - "template": tplName, - } - if params, ok := profileRaw["params"].(map[string]any); ok && len(params) > 0 { - instance["params"] = deepCopyMap(params) - } - if override, ok := profileRaw["override"].(map[string]any); ok && len(override) > 0 { - instance["override"] = deepCopyMap(override) - } - return []map[string]any{instance}, nil + if items, ok := profileRaw["instances"].([]any); ok { + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + instance, _ := item.(map[string]any) + if instance == nil { + return nil, fmt.Errorf("profile.instances entries must be objects") + } + cloned := deepCopyMap(instance) + if strings.TrimSpace(stringValue(cloned["template"])) == "" { + cloned["template"] = tplName + } + out = append(out, cloned) + } + return out, nil + } + name := strings.TrimSpace(stringValue(profileRaw["name"])) + if name == "" { + return nil, fmt.Errorf("profile must contain name or instances[]") + } + instance := map[string]any{ + "name": name, + "template": tplName, + } + if params, ok := profileRaw["params"].(map[string]any); ok && len(params) > 0 { + instance["params"] = deepCopyMap(params) + } + if override, ok := profileRaw["override"].(map[string]any); ok && len(override) > 0 { + instance["override"] = deepCopyMap(override) + } + return []map[string]any{instance}, nil } func mergeRuntimeTemplateParams(instances []map[string]any, sharedParams map[string]any) ([]map[string]any, error) { - if len(sharedParams) == 0 { - return instances, nil - } - out := make([]map[string]any, 0, len(instances)) - for _, item := range instances { - inst := deepCopyMap(item) - params, _ := inst["params"].(map[string]any) - if params == nil { - params = map[string]any{} - } - inst["params"] = deepMergeMap(sharedParams, params) - out = append(out, inst) - } - return out, nil + if len(sharedParams) == 0 { + return instances, nil + } + out := make([]map[string]any, 0, len(instances)) + for _, item := range instances { + inst := deepCopyMap(item) + params, _ := inst["params"].(map[string]any) + if params == nil { + params = map[string]any{} + } + inst["params"] = deepMergeMap(sharedParams, params) + out = append(out, inst) + } + return out, nil } func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string, instance map[string]any) (string, map[string]any, map[string]any, error) { - instanceName := strings.TrimSpace(stringValue(instance["name"])) - if instanceName == "" { - return "", nil, nil, fmt.Errorf("scene instance name is required") - } - tplName, err := runtimeTemplateName(templateRaw, templatePath) - if err != nil { - return "", nil, nil, err - } - boundName := tplName + "__" + instanceName - context, err := buildRuntimeBindingContext(instance) - if err != nil { - return "", nil, nil, err - } - templateBody, err := runtimeTemplateBody(templateRaw) - if err != nil { - return "", nil, nil, err - } - slotRequirements, err := runtimeSlotRequirements(templateRaw) - if err != nil { - return "", nil, nil, err - } - renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context, slotRequirements) - if err != nil { - return "", nil, nil, err - } - renderedTemplate, _ := renderedTemplateAny.(map[string]any) - renderedInstance := map[string]any{ - "name": instanceName, - "template": boundName, - } - if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 { - renderedInstance["scene_meta"] = deepCopyMap(sceneMeta) - } - if params, ok := instance["params"].(map[string]any); ok && len(params) > 0 { - renderedInstance["params"] = deepCopyMap(params) - } - return boundName, renderedTemplate, renderedInstance, nil + instanceName := strings.TrimSpace(stringValue(instance["name"])) + if instanceName == "" { + return "", nil, nil, fmt.Errorf("scene instance name is required") + } + tplName, err := runtimeTemplateName(templateRaw, templatePath) + if err != nil { + return "", nil, nil, err + } + boundName := tplName + "__" + instanceName + context, err := buildRuntimeBindingContext(instance) + if err != nil { + return "", nil, nil, err + } + templateBody, err := runtimeTemplateBody(templateRaw) + if err != nil { + return "", nil, nil, err + } + slotRequirements, err := runtimeSlotRequirements(templateRaw) + if err != nil { + return "", nil, nil, err + } + renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context, slotRequirements) + if err != nil { + return "", nil, nil, err + } + renderedTemplate, _ := renderedTemplateAny.(map[string]any) + renderedInstance := map[string]any{ + "name": instanceName, + "template": boundName, + } + if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 { + renderedInstance["scene_meta"] = deepCopyMap(sceneMeta) + } + if params, ok := instance["params"].(map[string]any); ok && len(params) > 0 { + renderedInstance["params"] = deepCopyMap(params) + } + return boundName, renderedTemplate, renderedInstance, nil } func runtimeSlotRequirements(templateRaw map[string]any) (map[string]bool, error) { - group, err := parseTemplateSlots(templateRaw) - if err != nil { - return nil, err - } - out := map[string]bool{} - for _, slot := range group.Inputs { - out[slot.Name] = slot.Required - } - for _, slot := range group.Services { - out[slot.Name] = slot.Required - } - for _, slot := range group.Outputs { - out[slot.Name] = slot.Required - } - return out, nil + group, err := parseTemplateSlots(templateRaw) + if err != nil { + return nil, err + } + out := map[string]bool{} + for _, slot := range group.Inputs { + out[slot.Name] = slot.Required + } + for _, slot := range group.Services { + out[slot.Name] = slot.Required + } + for _, slot := range group.Outputs { + out[slot.Name] = slot.Required + } + return out, nil } func buildRuntimeBindingContext(instance map[string]any) (map[string]any, error) { - context := map[string]any{} - if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 { - context["scene"] = deepCopyMap(sceneMeta) - } - for _, groupName := range []string{"input_bindings", "service_bindings", "output_bindings"} { - group, _ := instance[groupName].(map[string]any) - for slotName, raw := range group { - entry, _ := raw.(map[string]any) - if entry == nil { - return nil, fmt.Errorf("binding entry must be an object") - } - context[slotName] = resolvedRuntimeBindingValue(entry) - } - } - return context, nil + context := map[string]any{} + if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 { + context["scene"] = deepCopyMap(sceneMeta) + } + for _, groupName := range []string{"input_bindings", "service_bindings", "output_bindings"} { + group, _ := instance[groupName].(map[string]any) + for slotName, raw := range group { + entry, _ := raw.(map[string]any) + if entry == nil { + return nil, fmt.Errorf("binding entry must be an object") + } + context[slotName] = resolvedRuntimeBindingValue(entry) + } + } + return context, nil } func resolvedRuntimeBindingValue(entry map[string]any) map[string]any { - if resolved, ok := entry["resolved"].(map[string]any); ok && resolved != nil { - return deepCopyMap(resolved) - } - return deepCopyMap(entry) + if resolved, ok := entry["resolved"].(map[string]any); ok && resolved != nil { + return deepCopyMap(resolved) + } + return deepCopyMap(entry) } func expandRuntimeSlotTokens(value any, context map[string]any, slotRequirements map[string]bool) (any, error) { - switch typed := value.(type) { - case map[string]any: - out := make(map[string]any, len(typed)) - for key, item := range typed { - expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements) - if err != nil { - return nil, err - } - out[key] = expanded - } - return out, nil - case []any: - out := make([]any, 0, len(typed)) - for _, item := range typed { - expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements) - if err != nil { - return nil, err - } - out = append(out, expanded) - } - return out, nil - case string: - match := slotTokenRE.FindStringSubmatch(strings.TrimSpace(typed)) - if len(match) != 3 { - return typed, nil - } - required, known := slotRequirements[match[1]] - if !known { - required = true - } - slotValues, _ := context[match[1]].(map[string]any) - if slotValues == nil { - if !required { - return "", nil - } - return nil, fmt.Errorf("required slot '%s' is not bound", match[1]) - } - fieldValue, ok := slotValues[match[2]] - if !ok { - if !required { - return "", nil - } - return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2]) - } - return deepCopyAny(fieldValue), nil - default: - return deepCopyAny(value), nil - } + switch typed := value.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for key, item := range typed { + expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements) + if err != nil { + return nil, err + } + out[key] = expanded + } + return out, nil + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements) + if err != nil { + return nil, err + } + out = append(out, expanded) + } + return out, nil + case string: + match := slotTokenRE.FindStringSubmatch(strings.TrimSpace(typed)) + if len(match) != 3 { + return typed, nil + } + required, known := slotRequirements[match[1]] + if !known { + required = true + } + slotValues, _ := context[match[1]].(map[string]any) + if slotValues == nil { + if !required { + return "", nil + } + return nil, fmt.Errorf("required slot '%s' is not bound", match[1]) + } + fieldValue, ok := slotValues[match[2]] + if !ok { + if !required { + return "", nil + } + return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2]) + } + return deepCopyAny(fieldValue), nil + default: + return deepCopyAny(value), nil + } } func applyRuntimeOverlay(root map[string]any, overlay map[string]any) (map[string]any, error) { - out := deepCopyMap(root) - for _, key := range []string{"global", "queue", "templates"} { - if value, ok := overlay[key]; ok { - out[key] = deepMergeAny(out[key], value) - } - } - if rawPatches, ok := overlay["instance_overrides"]; ok { - patches, _ := rawPatches.(map[string]any) - if patches == nil { - return nil, fmt.Errorf("overlay.instance_overrides must be an object") - } - instances, _ := out["instances"].([]any) - mergedInstances := make([]any, 0, len(instances)) - for _, item := range instances { - instance, _ := item.(map[string]any) - merged := deepCopyMap(instance) - if patch, ok := patches["*"].(map[string]any); ok { - merged = mergeRuntimeInstancePatch(merged, patch) - } - name := stringValue(merged["name"]) - if patch, ok := patches[name].(map[string]any); ok { - merged = mergeRuntimeInstancePatch(merged, patch) - } - mergedInstances = append(mergedInstances, merged) - } - out["instances"] = mergedInstances - } - if rawInstances, ok := overlay["instances"]; ok { - patchList, _ := rawInstances.([]any) - if patchList == nil { - return nil, fmt.Errorf("overlay.instances must be an array") - } - instances, _ := out["instances"].([]any) - byName := map[string]int{} - for i, item := range instances { - instance, _ := item.(map[string]any) - byName[stringValue(instance["name"])] = i - } - for _, item := range patchList { - patch, _ := item.(map[string]any) - name := stringValue(patch["name"]) - if patch == nil || name == "" { - return nil, fmt.Errorf("overlay.instances entries must be objects with name") - } - idx, ok := byName[name] - if !ok { - return nil, fmt.Errorf("overlay instance not found in profile: %s", name) - } - instance, _ := instances[idx].(map[string]any) - instances[idx] = mergeRuntimeInstancePatch(instance, patch) - } - out["instances"] = instances - } - return out, nil + out := deepCopyMap(root) + for _, key := range []string{"global", "queue", "templates"} { + if value, ok := overlay[key]; ok { + out[key] = deepMergeAny(out[key], value) + } + } + if rawPatches, ok := overlay["instance_overrides"]; ok { + patches, _ := rawPatches.(map[string]any) + if patches == nil { + return nil, fmt.Errorf("overlay.instance_overrides must be an object") + } + instances, _ := out["instances"].([]any) + mergedInstances := make([]any, 0, len(instances)) + for _, item := range instances { + instance, _ := item.(map[string]any) + merged := deepCopyMap(instance) + if patch, ok := patches["*"].(map[string]any); ok { + merged = mergeRuntimeInstancePatch(merged, patch) + } + name := stringValue(merged["name"]) + if patch, ok := patches[name].(map[string]any); ok { + merged = mergeRuntimeInstancePatch(merged, patch) + } + mergedInstances = append(mergedInstances, merged) + } + out["instances"] = mergedInstances + } + if rawInstances, ok := overlay["instances"]; ok { + patchList, _ := rawInstances.([]any) + if patchList == nil { + return nil, fmt.Errorf("overlay.instances must be an array") + } + instances, _ := out["instances"].([]any) + byName := map[string]int{} + for i, item := range instances { + instance, _ := item.(map[string]any) + byName[stringValue(instance["name"])] = i + } + for _, item := range patchList { + patch, _ := item.(map[string]any) + name := stringValue(patch["name"]) + if patch == nil || name == "" { + return nil, fmt.Errorf("overlay.instances entries must be objects with name") + } + idx, ok := byName[name] + if !ok { + return nil, fmt.Errorf("overlay instance not found in profile: %s", name) + } + instance, _ := instances[idx].(map[string]any) + instances[idx] = mergeRuntimeInstancePatch(instance, patch) + } + out["instances"] = instances + } + return out, nil } func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) map[string]any { - merged := deepCopyMap(instance) - if params, ok := patch["params"].(map[string]any); ok { - existing, _ := merged["params"].(map[string]any) - merged["params"] = deepMergeMap(existing, params) - } - if override, ok := patch["override"].(map[string]any); ok { - existing, _ := merged["override"].(map[string]any) - merged["override"] = deepMergeMap(existing, override) - } - for key, value := range patch { - if key == "name" || key == "template" || key == "params" || key == "override" { - continue - } - merged[key] = deepMergeAny(merged[key], value) - } - return merged + merged := deepCopyMap(instance) + if params, ok := patch["params"].(map[string]any); ok { + existing, _ := merged["params"].(map[string]any) + merged["params"] = deepMergeMap(existing, params) + } + if override, ok := patch["override"].(map[string]any); ok { + existing, _ := merged["override"].(map[string]any) + merged["override"] = deepMergeMap(existing, override) + } + for key, value := range patch { + if key == "name" || key == "template" || key == "params" || key == "override" { + continue + } + merged[key] = deepMergeAny(merged[key], value) + } + return merged } func buildRuntimeMetadata(templateName string, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, root map[string]any, metadata map[string]any) map[string]any { - instanceNames := make([]string, 0) - instanceDisplayNames := make([]string, 0) - instances, _ := root["instances"].([]any) - for _, item := range instances { - instance, _ := item.(map[string]any) - if name := strings.TrimSpace(stringValue(instance["name"])); name != "" { - instanceNames = append(instanceNames, name) - } - if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok { - if displayName := strings.TrimSpace(stringValue(sceneMeta["display_name"])); displayName != "" { - instanceDisplayNames = append(instanceDisplayNames, displayName) - } - } - } - overlayNames := make([]any, 0, len(overlays)) - overlayPaths := make([]any, 0, len(overlays)) - for _, overlay := range overlays { - overlayNames = append(overlayNames, overlay.Name) - overlayPaths = append(overlayPaths, overlay.Path) - } - out := map[string]any{ - "template": templateName, - "template_path": templatePath, - "profile": strings.TrimSpace(firstString(profileRaw["name"], strings.TrimSuffix(filepath.Base(profilePath), filepath.Ext(profilePath)))), - "business_name": strings.TrimSpace(stringValue(profileRaw["business_name"])), - "profile_path": profilePath, - "instance_names": instanceNames, - "instance_display_names": instanceDisplayNames, - "overlays": overlayNames, - "overlay_paths": overlayPaths, - } - for key, value := range metadata { - out[key] = deepCopyAny(value) - } - return out + instanceNames := make([]string, 0) + instanceDisplayNames := make([]string, 0) + instances, _ := root["instances"].([]any) + for _, item := range instances { + instance, _ := item.(map[string]any) + if name := strings.TrimSpace(stringValue(instance["name"])); name != "" { + instanceNames = append(instanceNames, name) + } + if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok { + if displayName := strings.TrimSpace(stringValue(sceneMeta["display_name"])); displayName != "" { + instanceDisplayNames = append(instanceDisplayNames, displayName) + } + } + } + overlayNames := make([]any, 0, len(overlays)) + overlayPaths := make([]any, 0, len(overlays)) + for _, overlay := range overlays { + overlayNames = append(overlayNames, overlay.Name) + overlayPaths = append(overlayPaths, overlay.Path) + } + out := map[string]any{ + "template": templateName, + "template_path": templatePath, + "profile": strings.TrimSpace(firstString(profileRaw["name"], strings.TrimSuffix(filepath.Base(profilePath), filepath.Ext(profilePath)))), + "business_name": strings.TrimSpace(stringValue(profileRaw["business_name"])), + "profile_path": profilePath, + "instance_names": instanceNames, + "instance_display_names": instanceDisplayNames, + "overlays": overlayNames, + "overlay_paths": overlayPaths, + } + for key, value := range metadata { + out[key] = deepCopyAny(value) + } + return out } func deepMergeMap(base map[string]any, override map[string]any) map[string]any { - if base == nil { - base = map[string]any{} - } - return deepMergeAny(base, override).(map[string]any) + if base == nil { + base = map[string]any{} + } + return deepMergeAny(base, override).(map[string]any) } func deepMergeAny(base any, override any) any { - baseMap, baseIsMap := base.(map[string]any) - overrideMap, overrideIsMap := override.(map[string]any) - if baseIsMap && overrideIsMap { - merged := deepCopyMap(baseMap) - for key, value := range overrideMap { - if existing, ok := merged[key]; ok { - merged[key] = deepMergeAny(existing, value) - } else { - merged[key] = deepCopyAny(value) - } - } - return merged - } - return deepCopyAny(override) + baseMap, baseIsMap := base.(map[string]any) + overrideMap, overrideIsMap := override.(map[string]any) + if baseIsMap && overrideIsMap { + merged := deepCopyMap(baseMap) + for key, value := range overrideMap { + if existing, ok := merged[key]; ok { + merged[key] = deepMergeAny(existing, value) + } else { + merged[key] = deepCopyAny(value) + } + } + return merged + } + return deepCopyAny(override) } func deepCopyMap(in map[string]any) map[string]any { - if in == nil { - return map[string]any{} - } - out, _ := deepCopyAny(in).(map[string]any) - return out + if in == nil { + return map[string]any{} + } + out, _ := deepCopyAny(in).(map[string]any) + return out } func deepCopyAny(value any) any { - if value == nil { - return nil - } - body, err := json.Marshal(value) - if err != nil { - return value - } - var out any - if err := json.Unmarshal(body, &out); err != nil { - return value - } - return out + if value == nil { + return nil + } + body, err := json.Marshal(value) + if err != nil { + return value + } + var out any + if err := json.Unmarshal(body, &out); err != nil { + return value + } + return out } diff --git a/internal/service/discovery.go b/internal/service/discovery.go index 4a9d482..40283d9 100644 --- a/internal/service/discovery.go +++ b/internal/service/discovery.go @@ -1,167 +1,167 @@ package service import ( - "encoding/json" - "net" - "strings" - "time" + "encoding/json" + "net" + "strings" + "time" - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/models" - "github.com/google/uuid" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/models" + "github.com/google/uuid" ) const discoveryMagicV1 = "RK3588SYS_DISCOVERY_V1" type DiscoveryService struct { - cfg *config.Config - registry *RegistryService + cfg *config.Config + registry *RegistryService } func NewDiscoveryService(cfg *config.Config, registry *RegistryService) *DiscoveryService { - return &DiscoveryService{ - cfg: cfg, - registry: registry, - } + return &DiscoveryService{ + cfg: cfg, + registry: registry, + } } func (s *DiscoveryService) SearchDefault() ([]*models.Device, error) { - if s == nil || s.cfg == nil { - return nil, nil - } - timeoutMs := s.cfg.DiscoveryTimeoutMs - if timeoutMs <= 0 { - timeoutMs = 1200 - } - return s.Search(timeoutMs) + if s == nil || s.cfg == nil { + return nil, nil + } + timeoutMs := s.cfg.DiscoveryTimeoutMs + if timeoutMs <= 0 { + timeoutMs = 1200 + } + return s.Search(timeoutMs) } func (s *DiscoveryService) Search(timeoutMs int) ([]*models.Device, error) { - reqID := uuid.NewString() - payload, _ := json.Marshal(map[string]interface{}{ - "type": "discover", - "req_id": reqID, - "reply_port": 0, - }) - msg := []byte(discoveryMagicV1 + "\n" + string(payload)) + reqID := uuid.NewString() + payload, _ := json.Marshal(map[string]interface{}{ + "type": "discover", + "req_id": reqID, + "reply_port": 0, + }) + msg := []byte(discoveryMagicV1 + "\n" + string(payload)) - // Listen for replies - addr, err := net.ResolveUDPAddr("udp", ":0") // Random port for receiving - if err != nil { - return nil, err - } - conn, err := net.ListenUDP("udp", addr) - if err != nil { - return nil, err - } - defer conn.Close() + // Listen for replies + addr, err := net.ResolveUDPAddr("udp", ":0") // Random port for receiving + if err != nil { + return nil, err + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + return nil, err + } + defer conn.Close() - // Send Broadcast to all interfaces - interfaces, err := net.Interfaces() - if err == nil { - for _, iface := range interfaces { - if iface.Flags&net.FlagBroadcast != 0 && iface.Flags&net.FlagUp != 0 { - addrs, err := iface.Addrs() - if err != nil { - continue - } - for _, addr := range addrs { - if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil { - // Calculate broadcast address - ip := ipnet.IP.To4() - mask := ipnet.Mask - broadcast := make(net.IP, len(ip)) - for i := 0; i < len(ip); i++ { - broadcast[i] = ip[i] | ^mask[i] - } - broadcastAddr := &net.UDPAddr{ - IP: broadcast, - Port: s.cfg.DiscoveryPort, - } - _, _ = conn.WriteToUDP(msg, broadcastAddr) - } - } - } - } - } + // Send Broadcast to all interfaces + interfaces, err := net.Interfaces() + if err == nil { + for _, iface := range interfaces { + if iface.Flags&net.FlagBroadcast != 0 && iface.Flags&net.FlagUp != 0 { + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil { + // Calculate broadcast address + ip := ipnet.IP.To4() + mask := ipnet.Mask + broadcast := make(net.IP, len(ip)) + for i := 0; i < len(ip); i++ { + broadcast[i] = ip[i] | ^mask[i] + } + broadcastAddr := &net.UDPAddr{ + IP: broadcast, + Port: s.cfg.DiscoveryPort, + } + _, _ = conn.WriteToUDP(msg, broadcastAddr) + } + } + } + } + } - stop := time.After(time.Duration(timeoutMs) * time.Millisecond) - found := make(map[string]*models.Device) + stop := time.After(time.Duration(timeoutMs) * time.Millisecond) + found := make(map[string]*models.Device) - for { - select { - case <-stop: - // Convert map to slice - list := make([]*models.Device, 0, len(found)) - for _, d := range found { - list = append(list, d) - } - return list, nil - default: - conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) - buf := make([]byte, 2048) - n, raddr, err := conn.ReadFromUDP(buf) - if err != nil { - continue - } + for { + select { + case <-stop: + // Convert map to slice + list := make([]*models.Device, 0, len(found)) + for _, d := range found { + list = append(list, d) + } + return list, nil + default: + conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + buf := make([]byte, 2048) + n, raddr, err := conn.ReadFromUDP(buf) + if err != nil { + continue + } - text := strings.TrimSpace(string(buf[:n])) - lines := strings.SplitN(text, "\n", 3) - if len(lines) < 2 { - continue - } - if strings.TrimSpace(lines[0]) != discoveryMagicV1 { - continue - } + text := strings.TrimSpace(string(buf[:n])) + lines := strings.SplitN(text, "\n", 3) + if len(lines) < 2 { + continue + } + if strings.TrimSpace(lines[0]) != discoveryMagicV1 { + continue + } - var reply struct { - Type string `json:"type"` - ReqID string `json:"req_id"` - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` - Hostname string `json:"hostname"` - IP string `json:"ip"` - AgentPort int `json:"agent_port"` - MediaPort int `json:"media_port"` - Version string `json:"version"` - BuildID string `json:"build_id"` - GitSha string `json:"git_sha"` - UptimeSec int64 `json:"uptime_sec"` - InstanceName string `json:"instance_name"` - InstanceDisplayName string `json:"instance_display_name"` - ConfigID string `json:"config_id"` - ConfigVersion string `json:"config_version"` - Template string `json:"template"` - Profile string `json:"profile"` - Overlays []string `json:"overlays"` - } - if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &reply); err != nil { - continue - } - if reply.Type != "discover_reply" || reply.ReqID != reqID || reply.DeviceID == "" { - continue - } + var reply struct { + Type string `json:"type"` + ReqID string `json:"req_id"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + AgentPort int `json:"agent_port"` + MediaPort int `json:"media_port"` + Version string `json:"version"` + BuildID string `json:"build_id"` + GitSha string `json:"git_sha"` + UptimeSec int64 `json:"uptime_sec"` + InstanceName string `json:"instance_name"` + InstanceDisplayName string `json:"instance_display_name"` + ConfigID string `json:"config_id"` + ConfigVersion string `json:"config_version"` + Template string `json:"template"` + Profile string `json:"profile"` + Overlays []string `json:"overlays"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &reply); err != nil { + continue + } + if reply.Type != "discover_reply" || reply.ReqID != reqID || reply.DeviceID == "" { + continue + } - dev := &models.Device{ - DeviceID: reply.DeviceID, - DeviceName: reply.DeviceName, - Hostname: reply.Hostname, - IP: reply.IP, - AgentPort: reply.AgentPort, - MediaPort: reply.MediaPort, - Version: reply.Version, - BuildID: reply.BuildID, - GitSha: reply.GitSha, - UptimeSec: reply.UptimeSec, - InstanceName: reply.InstanceName, - InstanceDisplayName: reply.InstanceDisplayName, - } - if dev.IP == "" { - dev.IP = raddr.IP.String() - } + dev := &models.Device{ + DeviceID: reply.DeviceID, + DeviceName: reply.DeviceName, + Hostname: reply.Hostname, + IP: reply.IP, + AgentPort: reply.AgentPort, + MediaPort: reply.MediaPort, + Version: reply.Version, + BuildID: reply.BuildID, + GitSha: reply.GitSha, + UptimeSec: reply.UptimeSec, + InstanceName: reply.InstanceName, + InstanceDisplayName: reply.InstanceDisplayName, + } + if dev.IP == "" { + dev.IP = raddr.IP.String() + } - s.registry.UpdateDevice(dev) - found[dev.DeviceID] = dev - } - } + s.registry.UpdateDevice(dev) + found[dev.DeviceID] = dev + } + } } diff --git a/internal/service/face_gallery_builder.go b/internal/service/face_gallery_builder.go index d589e93..dd0ed74 100644 --- a/internal/service/face_gallery_builder.go +++ b/internal/service/face_gallery_builder.go @@ -1,58 +1,58 @@ package service import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" ) type FaceGalleryBuilder struct { - PythonExe string // path to python.exe - BuildScript string // path to build_gallery.py - DetModel string // path to face detection onnx - RecogModel string // path to face recognition onnx - DetOutputsConfig string // detection output config JSON - DatasetDir string // dataset root: person_name/*.jpg - OutputDB string // output face_gallery.db path - ExpectedDim int // embedding dimension, default 512 + PythonExe string // path to python.exe + BuildScript string // path to build_gallery.py + DetModel string // path to face detection onnx + RecogModel string // path to face recognition onnx + DetOutputsConfig string // detection output config JSON + DatasetDir string // dataset root: person_name/*.jpg + OutputDB string // output face_gallery.db path + ExpectedDim int // embedding dimension, default 512 } func (b *FaceGalleryBuilder) Build() (string, error) { - if b.ExpectedDim <= 0 { - b.ExpectedDim = 512 - } - // Allow python to be just "python" for PATH lookup - pythonExe := b.PythonExe - if _, err := os.Stat(b.PythonExe); err != nil { - pythonExe = "python" - } + if b.ExpectedDim <= 0 { + b.ExpectedDim = 512 + } + // Allow python to be just "python" for PATH lookup + pythonExe := b.PythonExe + if _, err := os.Stat(b.PythonExe); err != nil { + pythonExe = "python" + } - // Ensure dataset and output directories exist - if _, err := os.Stat(b.DatasetDir); os.IsNotExist(err) { - return "", fmt.Errorf("dataset directory not found: %s", b.DatasetDir) - } - outDir := filepath.Dir(b.OutputDB) - if err := os.MkdirAll(outDir, 0o755); err != nil { - return "", fmt.Errorf("create output directory: %w", err) - } + // Ensure dataset and output directories exist + if _, err := os.Stat(b.DatasetDir); os.IsNotExist(err) { + return "", fmt.Errorf("dataset directory not found: %s", b.DatasetDir) + } + outDir := filepath.Dir(b.OutputDB) + if err := os.MkdirAll(outDir, 0o755); err != nil { + return "", fmt.Errorf("create output directory: %w", err) + } - args := []string{ - b.BuildScript, - "--dataset", b.DatasetDir, - "--db_out", b.OutputDB, - "--det_model", b.DetModel, - "--recog_model", b.RecogModel, - "--det_outputs_config", b.DetOutputsConfig, - fmt.Sprintf("--expected_dim=%d", b.ExpectedDim), - } + args := []string{ + b.BuildScript, + "--dataset", b.DatasetDir, + "--db_out", b.OutputDB, + "--det_model", b.DetModel, + "--recog_model", b.RecogModel, + "--det_outputs_config", b.DetOutputsConfig, + fmt.Sprintf("--expected_dim=%d", b.ExpectedDim), + } - cmd := exec.Command(pythonExe, args...) - output, err := cmd.CombinedOutput() - outputStr := strings.TrimSpace(string(output)) - if err != nil { - return outputStr, fmt.Errorf("build failed: %w\n%s", err, outputStr) - } - return outputStr, nil + cmd := exec.Command(pythonExe, args...) + output, err := cmd.CombinedOutput() + outputStr := strings.TrimSpace(string(output)) + if err != nil { + return outputStr, fmt.Errorf("build failed: %w\n%s", err, outputStr) + } + return outputStr, nil } diff --git a/internal/service/model_management.go b/internal/service/model_management.go index bdfd8af..cce89b0 100644 --- a/internal/service/model_management.go +++ b/internal/service/model_management.go @@ -1,231 +1,231 @@ package service import ( - "crypto/sha256" - "encoding/json" - "encoding/hex" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" + "crypto/sha256" + "encoding/json" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" ) type ModelManagementService struct { - models *storage.ModelsRepo + models *storage.ModelsRepo } type InstalledModelStatus struct { - Name string `json:"name"` - FileName string `json:"file_name"` - SHA256 string `json:"sha256"` - SizeBytes int64 `json:"size_bytes"` - UpdatedAt int64 `json:"updated_at"` + Name string `json:"name"` + FileName string `json:"file_name"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + UpdatedAt int64 `json:"updated_at"` } type ModelStatusCell struct { - ModelName string `json:"model_name"` - FileName string `json:"file_name"` - Status string `json:"status"` - Version string `json:"version"` + ModelName string `json:"model_name"` + FileName string `json:"file_name"` + Status string `json:"status"` + Version string `json:"version"` } type ModelStatusRow struct { - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` - Online bool `json:"online"` - Cells []ModelStatusCell `json:"cells"` - ExtraModelCount int `json:"extra_model_count"` - ExtraModels []InstalledModelStatus `json:"extra_models"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + Online bool `json:"online"` + Cells []ModelStatusCell `json:"cells"` + ExtraModelCount int `json:"extra_model_count"` + ExtraModels []InstalledModelStatus `json:"extra_models"` } type ModelStatusSummary struct { - StandardModels int `json:"standard_models"` - Devices int `json:"devices"` - CompleteDevices int `json:"complete_devices"` - MissingDevices int `json:"missing_devices"` - MismatchDevices int `json:"mismatch_devices"` + StandardModels int `json:"standard_models"` + Devices int `json:"devices"` + CompleteDevices int `json:"complete_devices"` + MissingDevices int `json:"missing_devices"` + MismatchDevices int `json:"mismatch_devices"` } type ModelStatusBoard struct { - Summary ModelStatusSummary `json:"summary"` - Rows []ModelStatusRow `json:"rows"` + Summary ModelStatusSummary `json:"summary"` + Rows []ModelStatusRow `json:"rows"` } func NewModelManagementService(models *storage.ModelsRepo) *ModelManagementService { - return &ModelManagementService{models: models} + return &ModelManagementService{models: models} } func (s *ModelManagementService) SyncStandardModelsFromDirectory(dir string) error { - if s == nil || s.models == nil { - return fmt.Errorf("models repo is not configured") - } - dir = filepath.Clean(strings.TrimSpace(dir)) - if dir == "" { - return fmt.Errorf("standard models dir is empty") - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - for _, entry := range entries { - if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".rknn" { - continue - } - fullPath := filepath.Join(dir, entry.Name()) - sum, size, err := hashFile(fullPath) - if err != nil { - return err - } - record := storage.StandardModelRecord{ - Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())), - FileName: entry.Name(), - Version: "auto", - SHA256: sum, - SizeBytes: size, - ModelType: inferModelType(entry.Name()), - } - if err := s.models.Save(record); err != nil { - return err - } - } - return nil + if s == nil || s.models == nil { + return fmt.Errorf("models repo is not configured") + } + dir = filepath.Clean(strings.TrimSpace(dir)) + if dir == "" { + return fmt.Errorf("standard models dir is empty") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".rknn" { + continue + } + fullPath := filepath.Join(dir, entry.Name()) + sum, size, err := hashFile(fullPath) + if err != nil { + return err + } + record := storage.StandardModelRecord{ + Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())), + FileName: entry.Name(), + Version: "auto", + SHA256: sum, + SizeBytes: size, + ModelType: inferModelType(entry.Name()), + } + if err := s.models.Save(record); err != nil { + return err + } + } + return nil } func hashFile(path string) (string, int64, error) { - file, err := os.Open(path) - if err != nil { - return "", 0, err - } - defer file.Close() + file, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer file.Close() - hasher := sha256.New() - size, err := io.Copy(hasher, file) - if err != nil { - return "", 0, err - } - return hex.EncodeToString(hasher.Sum(nil)), size, nil + hasher := sha256.New() + size, err := io.Copy(hasher, file) + if err != nil { + return "", 0, err + } + return hex.EncodeToString(hasher.Sum(nil)), size, nil } func inferModelType(fileName string) string { - name := strings.ToLower(strings.TrimSpace(fileName)) - switch { - case strings.Contains(name, "face_det"), strings.Contains(name, "retinaface"), strings.Contains(name, "scrfd"): - return "face_detection" - case strings.Contains(name, "face_recog"), strings.Contains(name, "mobilefacenet"), strings.Contains(name, "arcface"): - return "face_recognition" - case strings.Contains(name, "ppe"): - return "ppe_detection" - case strings.Contains(name, "shoe"): - return "shoe_detection" - case strings.Contains(name, "object_det"), strings.Contains(name, "yolo"): - return "object_detection" - default: - return "other" - } + name := strings.ToLower(strings.TrimSpace(fileName)) + switch { + case strings.Contains(name, "face_det"), strings.Contains(name, "retinaface"), strings.Contains(name, "scrfd"): + return "face_detection" + case strings.Contains(name, "face_recog"), strings.Contains(name, "mobilefacenet"), strings.Contains(name, "arcface"): + return "face_recognition" + case strings.Contains(name, "ppe"): + return "ppe_detection" + case strings.Contains(name, "shoe"): + return "shoe_detection" + case strings.Contains(name, "object_det"), strings.Contains(name, "yolo"): + return "object_detection" + default: + return "other" + } } func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus) ModelStatusBoard { - board := ModelStatusBoard{ - Summary: ModelStatusSummary{ - StandardModels: len(standardModels), - Devices: len(devices), - }, - Rows: make([]ModelStatusRow, 0, len(devices)), - } - for _, device := range devices { - if device == nil { - continue - } - index := make(map[string]InstalledModelStatus, len(installed[device.DeviceID])) - for _, item := range installed[device.DeviceID] { - index[item.Name] = item - } - standardIndex := make(map[string]struct{}, len(standardModels)) - for _, model := range standardModels { - standardIndex[model.Name] = struct{}{} - } - row := ModelStatusRow{ - DeviceID: device.DeviceID, - DeviceName: device.DisplayName(), - Online: device.Online, - Cells: make([]ModelStatusCell, 0, len(standardModels)), - ExtraModels: make([]InstalledModelStatus, 0), - } - hasMissing := false - hasMismatch := false - for _, model := range standardModels { - cell := ModelStatusCell{ - ModelName: model.Name, - FileName: model.FileName, - Version: model.Version, - Status: "missing", - } - if item, ok := index[model.Name]; ok { - cell.FileName = item.FileName - if strings.EqualFold(strings.TrimSpace(item.SHA256), strings.TrimSpace(model.SHA256)) { - cell.Status = "ok" - } else { - cell.Status = "mismatch" - hasMismatch = true - } - } else { - hasMissing = true - } - if cell.Status == "missing" { - hasMissing = true - } - row.Cells = append(row.Cells, cell) - } - for _, item := range installed[device.DeviceID] { - if _, ok := standardIndex[item.Name]; ok { - continue - } - row.ExtraModels = append(row.ExtraModels, item) - } - sort.Slice(row.ExtraModels, func(i, j int) bool { - return row.ExtraModels[i].Name < row.ExtraModels[j].Name - }) - row.ExtraModelCount = len(row.ExtraModels) - switch { - case hasMismatch: - board.Summary.MismatchDevices++ - case hasMissing: - board.Summary.MissingDevices++ - default: - board.Summary.CompleteDevices++ - } - board.Rows = append(board.Rows, row) - } - sort.SliceStable(board.Rows, func(i, j int) bool { - return board.Rows[i].DeviceName < board.Rows[j].DeviceName - }) - return board + board := ModelStatusBoard{ + Summary: ModelStatusSummary{ + StandardModels: len(standardModels), + Devices: len(devices), + }, + Rows: make([]ModelStatusRow, 0, len(devices)), + } + for _, device := range devices { + if device == nil { + continue + } + index := make(map[string]InstalledModelStatus, len(installed[device.DeviceID])) + for _, item := range installed[device.DeviceID] { + index[item.Name] = item + } + standardIndex := make(map[string]struct{}, len(standardModels)) + for _, model := range standardModels { + standardIndex[model.Name] = struct{}{} + } + row := ModelStatusRow{ + DeviceID: device.DeviceID, + DeviceName: device.DisplayName(), + Online: device.Online, + Cells: make([]ModelStatusCell, 0, len(standardModels)), + ExtraModels: make([]InstalledModelStatus, 0), + } + hasMissing := false + hasMismatch := false + for _, model := range standardModels { + cell := ModelStatusCell{ + ModelName: model.Name, + FileName: model.FileName, + Version: model.Version, + Status: "missing", + } + if item, ok := index[model.Name]; ok { + cell.FileName = item.FileName + if strings.EqualFold(strings.TrimSpace(item.SHA256), strings.TrimSpace(model.SHA256)) { + cell.Status = "ok" + } else { + cell.Status = "mismatch" + hasMismatch = true + } + } else { + hasMissing = true + } + if cell.Status == "missing" { + hasMissing = true + } + row.Cells = append(row.Cells, cell) + } + for _, item := range installed[device.DeviceID] { + if _, ok := standardIndex[item.Name]; ok { + continue + } + row.ExtraModels = append(row.ExtraModels, item) + } + sort.Slice(row.ExtraModels, func(i, j int) bool { + return row.ExtraModels[i].Name < row.ExtraModels[j].Name + }) + row.ExtraModelCount = len(row.ExtraModels) + switch { + case hasMismatch: + board.Summary.MismatchDevices++ + case hasMissing: + board.Summary.MissingDevices++ + default: + board.Summary.CompleteDevices++ + } + board.Rows = append(board.Rows, row) + } + sort.SliceStable(board.Rows, func(i, j int) bool { + return board.Rows[i].DeviceName < board.Rows[j].DeviceName + }) + return board } func FetchInstalledModelStatuses(agent *AgentClient, device *models.Device) ([]InstalledModelStatus, error) { - if agent == nil || device == nil || strings.TrimSpace(device.IP) == "" || device.AgentPort <= 0 { - return nil, nil - } - body, status, err := agent.Do("GET", device.IP, device.AgentPort, "/v1/models/status", nil) - if err != nil { - return nil, err - } - if status != 200 { - return nil, fmt.Errorf("agent returned status %d", status) - } - var resp struct { - Models []InstalledModelStatus `json:"models"` - } - if err := json.Unmarshal(body, &resp); err != nil { - return nil, err - } - return resp.Models, nil + if agent == nil || device == nil || strings.TrimSpace(device.IP) == "" || device.AgentPort <= 0 { + return nil, nil + } + body, status, err := agent.Do("GET", device.IP, device.AgentPort, "/v1/models/status", nil) + if err != nil { + return nil, err + } + if status != 200 { + return nil, fmt.Errorf("agent returned status %d", status) + } + var resp struct { + Models []InstalledModelStatus `json:"models"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return resp.Models, nil } diff --git a/internal/service/model_management_test.go b/internal/service/model_management_test.go index 87b131f..11b0f7d 100644 --- a/internal/service/model_management_test.go +++ b/internal/service/model_management_test.go @@ -1,103 +1,103 @@ package service import ( - "os" - "path/filepath" - "testing" + "os" + "path/filepath" + "testing" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" ) func TestSyncStandardModelsFromDirectory(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "face_det_scrfd_500m_640_rk3588.rknn") - if err := os.WriteFile(path, []byte("model-bytes"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } + dir := t.TempDir() + path := filepath.Join(dir, "face_det_scrfd_500m_640_rk3588.rknn") + if err := os.WriteFile(path, []byte("model-bytes"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - modelsRepo := storage.NewModelsRepo(store.DB()) - svc := NewModelManagementService(modelsRepo) + modelsRepo := storage.NewModelsRepo(store.DB()) + svc := NewModelManagementService(modelsRepo) - if err := svc.SyncStandardModelsFromDirectory(dir); err != nil { - t.Fatalf("SyncStandardModelsFromDirectory: %v", err) - } + if err := svc.SyncStandardModelsFromDirectory(dir); err != nil { + t.Fatalf("SyncStandardModelsFromDirectory: %v", err) + } - items, err := modelsRepo.List() - if err != nil { - t.Fatalf("List: %v", err) - } - if len(items) != 1 || items[0].FileName != "face_det_scrfd_500m_640_rk3588.rknn" { - t.Fatalf("unexpected synced models: %#v", items) - } - if items[0].SHA256 == "" { - t.Fatalf("expected sha256 to be populated: %#v", items[0]) - } - if items[0].ModelType != "face_detection" { - t.Fatalf("expected inferred model type face_detection, got %#v", items[0]) - } + items, err := modelsRepo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 1 || items[0].FileName != "face_det_scrfd_500m_640_rk3588.rknn" { + t.Fatalf("unexpected synced models: %#v", items) + } + if items[0].SHA256 == "" { + t.Fatalf("expected sha256 to be populated: %#v", items[0]) + } + if items[0].ModelType != "face_detection" { + t.Fatalf("expected inferred model type face_detection, got %#v", items[0]) + } } func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) { - modelsList := []storage.StandardModelRecord{ - {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, - {Name: "face_recog", FileName: "face_recog.rknn", SHA256: "sha-2"}, - } - devices := []*models.Device{ - {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, - } - installed := map[string][]InstalledModelStatus{ - "edge-01": { - {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, - }, - } + modelsList := []storage.StandardModelRecord{ + {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, + {Name: "face_recog", FileName: "face_recog.rknn", SHA256: "sha-2"}, + } + devices := []*models.Device{ + {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, + } + installed := map[string][]InstalledModelStatus{ + "edge-01": { + {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, + }, + } - board := BuildModelStatusBoard(modelsList, devices, installed) + board := BuildModelStatusBoard(modelsList, devices, installed) - if board.Summary.MissingDevices != 1 { - t.Fatalf("expected one device with missing models, got %#v", board.Summary) - } - if len(board.Rows) != 1 || len(board.Rows[0].Cells) != 2 { - t.Fatalf("unexpected board rows: %#v", board.Rows) - } - if board.Rows[0].Cells[1].Status != "missing" { - t.Fatalf("expected second model to be missing, got %#v", board.Rows[0].Cells[1]) - } + if board.Summary.MissingDevices != 1 { + t.Fatalf("expected one device with missing models, got %#v", board.Summary) + } + if len(board.Rows) != 1 || len(board.Rows[0].Cells) != 2 { + t.Fatalf("unexpected board rows: %#v", board.Rows) + } + if board.Rows[0].Cells[1].Status != "missing" { + t.Fatalf("expected second model to be missing, got %#v", board.Rows[0].Cells[1]) + } } func TestBuildModelStatusBoardSeparatesExtraModels(t *testing.T) { - modelsList := []storage.StandardModelRecord{ - {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, - } - devices := []*models.Device{ - {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, - } - installed := map[string][]InstalledModelStatus{ - "edge-01": { - {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, - {Name: "best-640", FileName: "best-640.rknn", SHA256: "sha-x"}, - {Name: "mobilefacenet_arcface", FileName: "mobilefacenet_arcface.rknn", SHA256: "sha-y"}, - }, - } + modelsList := []storage.StandardModelRecord{ + {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, + } + devices := []*models.Device{ + {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, + } + installed := map[string][]InstalledModelStatus{ + "edge-01": { + {Name: "face_det", FileName: "face_det.rknn", SHA256: "sha-1"}, + {Name: "best-640", FileName: "best-640.rknn", SHA256: "sha-x"}, + {Name: "mobilefacenet_arcface", FileName: "mobilefacenet_arcface.rknn", SHA256: "sha-y"}, + }, + } - board := BuildModelStatusBoard(modelsList, devices, installed) + board := BuildModelStatusBoard(modelsList, devices, installed) - if len(board.Rows) != 1 { - t.Fatalf("unexpected board rows: %#v", board.Rows) - } - if got := board.Rows[0].ExtraModelCount; got != 2 { - t.Fatalf("expected 2 extra models, got %#v", board.Rows[0]) - } - if len(board.Rows[0].ExtraModels) != 2 { - t.Fatalf("expected extra model details to be preserved, got %#v", board.Rows[0].ExtraModels) - } - if board.Rows[0].ExtraModels[0].Name != "best-640" || board.Rows[0].ExtraModels[1].Name != "mobilefacenet_arcface" { - t.Fatalf("expected sorted extra models, got %#v", board.Rows[0].ExtraModels) - } + if len(board.Rows) != 1 { + t.Fatalf("unexpected board rows: %#v", board.Rows) + } + if got := board.Rows[0].ExtraModelCount; got != 2 { + t.Fatalf("expected 2 extra models, got %#v", board.Rows[0]) + } + if len(board.Rows[0].ExtraModels) != 2 { + t.Fatalf("expected extra model details to be preserved, got %#v", board.Rows[0].ExtraModels) + } + if board.Rows[0].ExtraModels[0].Name != "best-640" || board.Rows[0].ExtraModels[1].Name != "mobilefacenet_arcface" { + t.Fatalf("expected sorted extra models, got %#v", board.Rows[0].ExtraModels) + } } diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go index eabc8ac..702e5f4 100644 --- a/internal/service/profile_editor.go +++ b/internal/service/profile_editor.go @@ -1,471 +1,471 @@ package service import ( - "encoding/json" - "fmt" - "strconv" - "strings" + "encoding/json" + "fmt" + "strconv" + "strings" ) type ConfigProfileEditor struct { - Name string `json:"name"` - Path string `json:"path"` - PrimaryTemplateName string `json:"primary_template_name"` - BusinessName string `json:"business_name"` - Description string `json:"description"` - OverlayName string `json:"overlay_name"` - DeviceCode string `json:"device_code"` - SiteName string `json:"site_name"` - Queue ConfigProfileQueueEditor `json:"queue"` - Instances []ConfigProfileInstanceEditor `json:"instances"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + PrimaryTemplateName string `json:"primary_template_name"` + BusinessName string `json:"business_name"` + Description string `json:"description"` + OverlayName string `json:"overlay_name"` + DeviceCode string `json:"device_code"` + SiteName string `json:"site_name"` + Queue ConfigProfileQueueEditor `json:"queue"` + Instances []ConfigProfileInstanceEditor `json:"instances"` + Raw map[string]any `json:"raw"` } type ConfigProfileQueueEditor struct { - Size string `json:"size"` - Strategy string `json:"strategy"` + Size string `json:"size"` + Strategy string `json:"strategy"` } func DefaultConfigProfileQueue() ConfigProfileQueueEditor { - return ConfigProfileQueueEditor{ - Size: "8", - Strategy: "drop_oldest", - } + return ConfigProfileQueueEditor{ + Size: "8", + Strategy: "drop_oldest", + } } type InputBindingEditor struct { - VideoSourceRef string `json:"video_source_ref"` + VideoSourceRef string `json:"video_source_ref"` } type ServiceBindingEditor struct { - ServiceRef string `json:"service_ref"` + ServiceRef string `json:"service_ref"` } type OutputBindingEditor struct { - PublishHLSPath string `json:"publish_hls_path"` - PublishRTSPPort string `json:"publish_rtsp_port"` - PublishRTSPPath string `json:"publish_rtsp_path"` - ChannelNo string `json:"channel_no"` + PublishHLSPath string `json:"publish_hls_path"` + PublishRTSPPort string `json:"publish_rtsp_port"` + PublishRTSPPath string `json:"publish_rtsp_path"` + ChannelNo string `json:"channel_no"` } type ConfigProfileInstanceEditor struct { - Name string `json:"name"` - Template string `json:"template"` - VideoSourceRef string `json:"video_source_ref"` - DisplayName string `json:"display_name"` - SiteName string `json:"site_name"` - PublishHLSPath string `json:"publish_hls_path"` - PublishRTSPPort string `json:"publish_rtsp_port"` - PublishRTSPPath string `json:"publish_rtsp_path"` - ChannelNo string `json:"channel_no"` - InputBindings map[string]InputBindingEditor `json:"input_bindings,omitempty"` - ServiceBindings map[string]ServiceBindingEditor `json:"service_bindings,omitempty"` - OutputBindings map[string]OutputBindingEditor `json:"output_bindings,omitempty"` - AdvancedParams map[string]any `json:"advanced_params"` - Delete bool `json:"delete"` + Name string `json:"name"` + Template string `json:"template"` + VideoSourceRef string `json:"video_source_ref"` + DisplayName string `json:"display_name"` + SiteName string `json:"site_name"` + PublishHLSPath string `json:"publish_hls_path"` + PublishRTSPPort string `json:"publish_rtsp_port"` + PublishRTSPPath string `json:"publish_rtsp_path"` + ChannelNo string `json:"channel_no"` + InputBindings map[string]InputBindingEditor `json:"input_bindings,omitempty"` + ServiceBindings map[string]ServiceBindingEditor `json:"service_bindings,omitempty"` + OutputBindings map[string]OutputBindingEditor `json:"output_bindings,omitempty"` + AdvancedParams map[string]any `json:"advanced_params"` + Delete bool `json:"delete"` } func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEditor, error) { - raw, path, err := s.readAssetJSON("profiles", name) - if err != nil { - return nil, err - } - queueMap, _ := raw["queue"].(map[string]any) - templateName := stringValue(raw["primary_template_name"]) - instances, siteName, deviceCode, err := s.loadRecognitionUnitEditors(name, templateName) - if err != nil { - return nil, err - } - return &ConfigProfileEditor{ - Name: firstString(raw["name"], name), - Path: path, - PrimaryTemplateName: stringValue(raw["primary_template_name"]), - BusinessName: stringValue(raw["business_name"]), - Description: stringValue(raw["description"]), - OverlayName: firstOverlayName(raw), - DeviceCode: deviceCode, - SiteName: siteName, - Queue: ConfigProfileQueueEditor{ - Size: valueString(queueMap["size"]), - Strategy: stringValue(queueMap["strategy"]), - }, - Instances: instances, - Raw: raw, - }, nil + raw, path, err := s.readAssetJSON("profiles", name) + if err != nil { + return nil, err + } + queueMap, _ := raw["queue"].(map[string]any) + templateName := stringValue(raw["primary_template_name"]) + instances, siteName, deviceCode, err := s.loadRecognitionUnitEditors(name, templateName) + if err != nil { + return nil, err + } + return &ConfigProfileEditor{ + Name: firstString(raw["name"], name), + Path: path, + PrimaryTemplateName: stringValue(raw["primary_template_name"]), + BusinessName: stringValue(raw["business_name"]), + Description: stringValue(raw["description"]), + OverlayName: firstOverlayName(raw), + DeviceCode: deviceCode, + SiteName: siteName, + Queue: ConfigProfileQueueEditor{ + Size: valueString(queueMap["size"]), + Strategy: stringValue(queueMap["strategy"]), + }, + Instances: instances, + Raw: raw, + }, nil } func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor) (map[string]any, error) { - name := strings.TrimSpace(editor.Name) - if name == "" { - return nil, fmt.Errorf("profile name is required") - } - if err := validateConfigName(name); err != nil { - return nil, fmt.Errorf("invalid profile name: %w", err) - } + name := strings.TrimSpace(editor.Name) + if name == "" { + return nil, fmt.Errorf("profile name is required") + } + if err := validateConfigName(name); err != nil { + return nil, fmt.Errorf("invalid profile name: %w", err) + } - seen := map[string]struct{}{} - instances := make([]map[string]any, 0, len(editor.Instances)) - for _, inst := range editor.Instances { - if inst.Delete { - continue - } - instanceName := strings.TrimSpace(inst.Name) - if instanceName == "" { - return nil, fmt.Errorf("instance name is required") - } - if err := validateConfigName(instanceName); err != nil { - return nil, fmt.Errorf("invalid instance name %q: %w", instanceName, err) - } - if _, ok := seen[instanceName]; ok { - return nil, fmt.Errorf("duplicate instance name: %s", instanceName) - } - seen[instanceName] = struct{}{} + seen := map[string]struct{}{} + instances := make([]map[string]any, 0, len(editor.Instances)) + for _, inst := range editor.Instances { + if inst.Delete { + continue + } + instanceName := strings.TrimSpace(inst.Name) + if instanceName == "" { + return nil, fmt.Errorf("instance name is required") + } + if err := validateConfigName(instanceName); err != nil { + return nil, fmt.Errorf("invalid instance name %q: %w", instanceName, err) + } + if _, ok := seen[instanceName]; ok { + return nil, fmt.Errorf("duplicate instance name: %s", instanceName) + } + seen[instanceName] = struct{}{} - videoSourceRef := strings.TrimSpace(inputBindingRef(inst.InputBindings, "video_input_main")) - if videoSourceRef == "" { - videoSourceRef = strings.TrimSpace(inst.VideoSourceRef) - } - if videoSourceRef == "" { - return nil, fmt.Errorf("video source is required for %s", instanceName) - } + videoSourceRef := strings.TrimSpace(inputBindingRef(inst.InputBindings, "video_input_main")) + if videoSourceRef == "" { + videoSourceRef = strings.TrimSpace(inst.VideoSourceRef) + } + if videoSourceRef == "" { + return nil, fmt.Errorf("video source is required for %s", instanceName) + } - params := map[string]any{} - for key, value := range cloneMap(inst.AdvancedParams) { - params[key] = value - } + params := map[string]any{} + for key, value := range cloneMap(inst.AdvancedParams) { + params[key] = value + } - instance := map[string]any{ - "name": instanceName, - } - if len(params) > 0 { - instance["params"] = params - } - setString(instance, "template", inst.Template) - sceneMeta := map[string]any{} - setString(sceneMeta, "display_name", inst.DisplayName) - setString(sceneMeta, "site_name", firstString(inst.SiteName, editor.SiteName)) - setString(sceneMeta, "device_code", editor.DeviceCode) - if len(sceneMeta) > 0 { - instance["scene_meta"] = sceneMeta - } - inputBindings := buildInputBindingDocument(inst) - if len(inputBindings) > 0 { - instance["input_bindings"] = inputBindings - } - serviceBindings := buildServiceBindingDocument(inst) - if len(serviceBindings) > 0 { - instance["service_bindings"] = serviceBindings - } - outputBindings, err := buildOutputBindingDocument(inst) - if err != nil { - return nil, err - } - if len(outputBindings) > 0 { - instance["output_bindings"] = outputBindings - } - instances = append(instances, instance) - } - doc := map[string]any{ - "name": name, - "instances": instances, - } - setString(doc, "business_name", editor.BusinessName) - setString(doc, "description", editor.Description) - if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" { - doc["overlays"] = []any{overlayName} - } - normalizedQueue := editor.Queue - if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" { - normalizedQueue = DefaultConfigProfileQueue() - } - queue := map[string]any{} - if size := strings.TrimSpace(normalizedQueue.Size); size != "" { - value, err := strconv.Atoi(size) - if err != nil { - return nil, fmt.Errorf("queue size must be a number") - } - queue["size"] = value - } - setString(queue, "strategy", normalizedQueue.Strategy) - if len(queue) > 0 { - doc["queue"] = queue - } + instance := map[string]any{ + "name": instanceName, + } + if len(params) > 0 { + instance["params"] = params + } + setString(instance, "template", inst.Template) + sceneMeta := map[string]any{} + setString(sceneMeta, "display_name", inst.DisplayName) + setString(sceneMeta, "site_name", firstString(inst.SiteName, editor.SiteName)) + setString(sceneMeta, "device_code", editor.DeviceCode) + if len(sceneMeta) > 0 { + instance["scene_meta"] = sceneMeta + } + inputBindings := buildInputBindingDocument(inst) + if len(inputBindings) > 0 { + instance["input_bindings"] = inputBindings + } + serviceBindings := buildServiceBindingDocument(inst) + if len(serviceBindings) > 0 { + instance["service_bindings"] = serviceBindings + } + outputBindings, err := buildOutputBindingDocument(inst) + if err != nil { + return nil, err + } + if len(outputBindings) > 0 { + instance["output_bindings"] = outputBindings + } + instances = append(instances, instance) + } + doc := map[string]any{ + "name": name, + "instances": instances, + } + setString(doc, "business_name", editor.BusinessName) + setString(doc, "description", editor.Description) + if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" { + doc["overlays"] = []any{overlayName} + } + normalizedQueue := editor.Queue + if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" { + normalizedQueue = DefaultConfigProfileQueue() + } + queue := map[string]any{} + if size := strings.TrimSpace(normalizedQueue.Size); size != "" { + value, err := strconv.Atoi(size) + if err != nil { + return nil, fmt.Errorf("queue size must be a number") + } + queue["size"] = value + } + setString(queue, "strategy", normalizedQueue.Strategy) + if len(queue) > 0 { + doc["queue"] = queue + } - return doc, nil + return doc, nil } func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) error { - doc, err := s.buildSceneTemplateDocument(editor) - if err != nil { - return err - } - body, err := marshalConfigJSON(doc) - if err != nil { - return err - } - if s != nil && s.assets != nil { - templateName := strings.TrimSpace(editor.PrimaryTemplateName) - if templateName == "" { - templateName = firstProfileTemplate(editor.Instances) - } - return s.assets.SaveProfile( - strings.TrimSpace(editor.Name), - templateName, - strings.TrimSpace(editor.BusinessName), - strings.TrimSpace(editor.Description), - string(body), - ) - } - return fmt.Errorf("asset repository is not configured") + doc, err := s.buildSceneTemplateDocument(editor) + if err != nil { + return err + } + body, err := marshalConfigJSON(doc) + if err != nil { + return err + } + if s != nil && s.assets != nil { + templateName := strings.TrimSpace(editor.PrimaryTemplateName) + if templateName == "" { + templateName = firstProfileTemplate(editor.Instances) + } + return s.assets.SaveProfile( + strings.TrimSpace(editor.Name), + templateName, + strings.TrimSpace(editor.BusinessName), + strings.TrimSpace(editor.Description), + string(body), + ) + } + return fmt.Errorf("asset repository is not configured") } func (s *ConfigPreviewService) buildSceneTemplateDocument(editor ConfigProfileEditor) (map[string]any, error) { - name := strings.TrimSpace(editor.Name) - if name == "" { - return nil, fmt.Errorf("scene template name is required") - } - if err := validateConfigName(name); err != nil { - return nil, fmt.Errorf("invalid scene template name: %w", err) - } - doc := map[string]any{ - "name": name, - } - setString(doc, "primary_template_name", editor.PrimaryTemplateName) - setString(doc, "business_name", editor.BusinessName) - setString(doc, "description", editor.Description) - setString(doc, "site_name", editor.SiteName) - if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" { - doc["overlays"] = []any{overlayName} - } - normalizedQueue := editor.Queue - if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" { - normalizedQueue = DefaultConfigProfileQueue() - } - queue := map[string]any{} - if size := strings.TrimSpace(normalizedQueue.Size); size != "" { - value, err := strconv.Atoi(size) - if err != nil { - return nil, fmt.Errorf("queue size must be a number") - } - queue["size"] = value - } - setString(queue, "strategy", normalizedQueue.Strategy) - if len(queue) > 0 { - doc["queue"] = queue - } - return doc, nil + name := strings.TrimSpace(editor.Name) + if name == "" { + return nil, fmt.Errorf("scene template name is required") + } + if err := validateConfigName(name); err != nil { + return nil, fmt.Errorf("invalid scene template name: %w", err) + } + doc := map[string]any{ + "name": name, + } + setString(doc, "primary_template_name", editor.PrimaryTemplateName) + setString(doc, "business_name", editor.BusinessName) + setString(doc, "description", editor.Description) + setString(doc, "site_name", editor.SiteName) + if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" { + doc["overlays"] = []any{overlayName} + } + normalizedQueue := editor.Queue + if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" { + normalizedQueue = DefaultConfigProfileQueue() + } + queue := map[string]any{} + if size := strings.TrimSpace(normalizedQueue.Size); size != "" { + value, err := strconv.Atoi(size) + if err != nil { + return nil, fmt.Errorf("queue size must be a number") + } + queue["size"] = value + } + setString(queue, "strategy", normalizedQueue.Strategy) + if len(queue) > 0 { + doc["queue"] = queue + } + return doc, nil } func setString(m map[string]any, key string, value string) { - if strings.TrimSpace(value) != "" { - m[key] = strings.TrimSpace(value) - } + if strings.TrimSpace(value) != "" { + m[key] = strings.TrimSpace(value) + } } func firstOverlayName(raw map[string]any) string { - items, _ := raw["overlays"].([]any) - for _, item := range items { - if v := stringValue(item); strings.TrimSpace(v) != "" { - return strings.TrimSpace(v) - } - } - return "" + items, _ := raw["overlays"].([]any) + for _, item := range items { + if v := stringValue(item); strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" } func marshalConfigJSON(doc map[string]any) ([]byte, error) { - body, err := json.MarshalIndent(doc, "", " ") - if err != nil { - return nil, err - } - return append(body, '\n'), nil + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil, err + } + return append(body, '\n'), nil } func firstProfileTemplate(instances []ConfigProfileInstanceEditor) string { - for _, inst := range instances { - if inst.Delete { - continue - } - if v := strings.TrimSpace(inst.Template); v != "" { - return v - } - } - return "" + for _, inst := range instances { + if inst.Delete { + continue + } + if v := strings.TrimSpace(inst.Template); v != "" { + return v + } + } + return "" } func parseInputBindingEditors(raw any) map[string]InputBindingEditor { - items, _ := raw.(map[string]any) - if len(items) == 0 { - return nil - } - out := map[string]InputBindingEditor{} - for key, value := range items { - entry, _ := value.(map[string]any) - out[key] = InputBindingEditor{ - VideoSourceRef: stringValue(entry["video_source_ref"]), - } - } - return out + items, _ := raw.(map[string]any) + if len(items) == 0 { + return nil + } + out := map[string]InputBindingEditor{} + for key, value := range items { + entry, _ := value.(map[string]any) + out[key] = InputBindingEditor{ + VideoSourceRef: stringValue(entry["video_source_ref"]), + } + } + return out } func parseServiceBindingEditors(raw any) map[string]ServiceBindingEditor { - items, _ := raw.(map[string]any) - if len(items) == 0 { - return nil - } - out := map[string]ServiceBindingEditor{} - for key, value := range items { - entry, _ := value.(map[string]any) - out[key] = ServiceBindingEditor{ - ServiceRef: stringValue(entry["service_ref"]), - } - } - return out + items, _ := raw.(map[string]any) + if len(items) == 0 { + return nil + } + out := map[string]ServiceBindingEditor{} + for key, value := range items { + entry, _ := value.(map[string]any) + out[key] = ServiceBindingEditor{ + ServiceRef: stringValue(entry["service_ref"]), + } + } + return out } func parseOutputBindingEditors(raw any) map[string]OutputBindingEditor { - items, _ := raw.(map[string]any) - if len(items) == 0 { - return nil - } - out := map[string]OutputBindingEditor{} - for key, value := range items { - entry, _ := value.(map[string]any) - out[key] = OutputBindingEditor{ - PublishHLSPath: stringValue(entry["publish_hls_path"]), - PublishRTSPPort: valueString(entry["publish_rtsp_port"]), - PublishRTSPPath: stringValue(entry["publish_rtsp_path"]), - ChannelNo: stringValue(entry["channel_no"]), - } - } - return out + items, _ := raw.(map[string]any) + if len(items) == 0 { + return nil + } + out := map[string]OutputBindingEditor{} + for key, value := range items { + entry, _ := value.(map[string]any) + out[key] = OutputBindingEditor{ + PublishHLSPath: stringValue(entry["publish_hls_path"]), + PublishRTSPPort: valueString(entry["publish_rtsp_port"]), + PublishRTSPPath: stringValue(entry["publish_rtsp_path"]), + ChannelNo: stringValue(entry["channel_no"]), + } + } + return out } func inputBindingRef(bindings map[string]InputBindingEditor, slot string) string { - if len(bindings) == 0 { - return "" - } - return strings.TrimSpace(bindings[slot].VideoSourceRef) + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].VideoSourceRef) } func outputBindingValue(bindings map[string]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 "" - } + 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 buildInputBindingDocument(inst ConfigProfileInstanceEditor) map[string]any { - out := map[string]any{} - for key, value := range inst.InputBindings { - entry := map[string]any{} - setString(entry, "video_source_ref", value.VideoSourceRef) - if len(entry) > 0 { - out[key] = entry - } - } - if strings.TrimSpace(inst.VideoSourceRef) != "" { - if _, ok := out["video_input_main"]; !ok { - out["video_input_main"] = map[string]any{"video_source_ref": strings.TrimSpace(inst.VideoSourceRef)} - } - } - if len(out) == 0 { - return nil - } - return out + out := map[string]any{} + for key, value := range inst.InputBindings { + entry := map[string]any{} + setString(entry, "video_source_ref", value.VideoSourceRef) + if len(entry) > 0 { + out[key] = entry + } + } + if strings.TrimSpace(inst.VideoSourceRef) != "" { + if _, ok := out["video_input_main"]; !ok { + out["video_input_main"] = map[string]any{"video_source_ref": strings.TrimSpace(inst.VideoSourceRef)} + } + } + if len(out) == 0 { + return nil + } + return out } func buildServiceBindingDocument(inst ConfigProfileInstanceEditor) map[string]any { - out := map[string]any{} - for key, value := range inst.ServiceBindings { - entry := map[string]any{} - setString(entry, "service_ref", value.ServiceRef) - if len(entry) > 0 { - out[key] = entry - } - } - if len(out) == 0 { - return nil - } - return out + out := map[string]any{} + for key, value := range inst.ServiceBindings { + entry := map[string]any{} + setString(entry, "service_ref", value.ServiceRef) + if len(entry) > 0 { + out[key] = entry + } + } + if len(out) == 0 { + return nil + } + return out } func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) { - out := map[string]any{} - for key, value := range inst.OutputBindings { - if key == "stream_output_main" { - value = applyDefaultStreamOutputBinding(inst.Name, value) - } - entry, err := outputBindingEntry(value) - if err != nil { - return nil, err - } - if len(entry) > 0 { - out[key] = entry - } - } - if _, ok := out["stream_output_main"]; !ok { - entry, err := outputBindingEntry(applyDefaultStreamOutputBinding(inst.Name, OutputBindingEditor{ - PublishHLSPath: inst.PublishHLSPath, - PublishRTSPPort: inst.PublishRTSPPort, - PublishRTSPPath: inst.PublishRTSPPath, - ChannelNo: inst.ChannelNo, - })) - if err != nil { - return nil, err - } - if len(entry) > 0 { - out["stream_output_main"] = entry - } - } - if len(out) == 0 { - return nil, nil - } - return out, nil + out := map[string]any{} + for key, value := range inst.OutputBindings { + if key == "stream_output_main" { + value = applyDefaultStreamOutputBinding(inst.Name, value) + } + entry, err := outputBindingEntry(value) + if err != nil { + return nil, err + } + if len(entry) > 0 { + out[key] = entry + } + } + if _, ok := out["stream_output_main"]; !ok { + entry, err := outputBindingEntry(applyDefaultStreamOutputBinding(inst.Name, OutputBindingEditor{ + PublishHLSPath: inst.PublishHLSPath, + PublishRTSPPort: inst.PublishRTSPPort, + PublishRTSPPath: inst.PublishRTSPPath, + ChannelNo: inst.ChannelNo, + })) + if err != nil { + return nil, err + } + if len(entry) > 0 { + out["stream_output_main"] = entry + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil } func applyDefaultStreamOutputBinding(instanceName string, value OutputBindingEditor) OutputBindingEditor { - name := strings.TrimSpace(instanceName) - if name == "" { - return value - } - if strings.TrimSpace(value.PublishHLSPath) == "" { - value.PublishHLSPath = "./web/hls/" + name + "/index.m3u8" - } - if strings.TrimSpace(value.PublishRTSPPath) == "" { - value.PublishRTSPPath = "/live/" + name - } - if strings.TrimSpace(value.ChannelNo) == "" { - value.ChannelNo = name - } - return value + name := strings.TrimSpace(instanceName) + if name == "" { + return value + } + if strings.TrimSpace(value.PublishHLSPath) == "" { + value.PublishHLSPath = "./web/hls/" + name + "/index.m3u8" + } + if strings.TrimSpace(value.PublishRTSPPath) == "" { + value.PublishRTSPPath = "/live/" + name + } + if strings.TrimSpace(value.ChannelNo) == "" { + value.ChannelNo = name + } + return value } func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) { - entry := map[string]any{} - setString(entry, "publish_hls_path", value.PublishHLSPath) - setString(entry, "publish_rtsp_path", value.PublishRTSPPath) - setString(entry, "channel_no", value.ChannelNo) - if port := strings.TrimSpace(value.PublishRTSPPort); port != "" { - parsed, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("publish rtsp port must be a number") - } - entry["publish_rtsp_port"] = parsed - } - return entry, nil + entry := map[string]any{} + setString(entry, "publish_hls_path", value.PublishHLSPath) + setString(entry, "publish_rtsp_path", value.PublishRTSPPath) + setString(entry, "channel_no", value.ChannelNo) + if port := strings.TrimSpace(value.PublishRTSPPort); port != "" { + parsed, err := strconv.Atoi(port) + if err != nil { + return nil, fmt.Errorf("publish rtsp port must be a number") + } + entry["publish_rtsp_port"] = parsed + } + return entry, nil } diff --git a/internal/service/registry.go b/internal/service/registry.go index 419db61..c306ec9 100644 --- a/internal/service/registry.go +++ b/internal/service/registry.go @@ -1,190 +1,190 @@ package service import ( - "encoding/json" - "strings" - "sync" - "time" + "encoding/json" + "strings" + "sync" + "time" - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/models" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/models" ) type RegistryService struct { - cfg *config.Config - agent *AgentClient - discovery *DiscoveryService - repo DeviceRepository - mu sync.RWMutex - devices map[string]*models.Device + cfg *config.Config + agent *AgentClient + discovery *DiscoveryService + repo DeviceRepository + mu sync.RWMutex + devices map[string]*models.Device } type DeviceRepository interface { - Upsert(dev *models.Device) error - List() ([]*models.Device, error) + Upsert(dev *models.Device) error + List() ([]*models.Device, error) } func NewRegistryService(cfg *config.Config, agent *AgentClient, repo ...DeviceRepository) *RegistryService { - var deviceRepo DeviceRepository - if len(repo) > 0 { - deviceRepo = repo[0] - } - s := &RegistryService{ - cfg: cfg, - agent: agent, - repo: deviceRepo, - devices: make(map[string]*models.Device), - } - // Load persisted devices from DB so offline devices still appear in the list - if deviceRepo != nil { - if saved, err := deviceRepo.List(); err == nil { - for _, dev := range saved { - if dev == nil || dev.DeviceID == "" { - continue - } - dev.Online = false // mark offline until discovery confirms - s.devices[dev.DeviceID] = dev - } - } - } - go s.startPruning() - if agent != nil { - go s.startGraphPolling() - } - go s.startDiscoveryPolling() - return s + var deviceRepo DeviceRepository + if len(repo) > 0 { + deviceRepo = repo[0] + } + s := &RegistryService{ + cfg: cfg, + agent: agent, + repo: deviceRepo, + devices: make(map[string]*models.Device), + } + // Load persisted devices from DB so offline devices still appear in the list + if deviceRepo != nil { + if saved, err := deviceRepo.List(); err == nil { + for _, dev := range saved { + if dev == nil || dev.DeviceID == "" { + continue + } + dev.Online = false // mark offline until discovery confirms + s.devices[dev.DeviceID] = dev + } + } + } + go s.startPruning() + if agent != nil { + go s.startGraphPolling() + } + go s.startDiscoveryPolling() + return s } func (s *RegistryService) SetDiscovery(d *DiscoveryService) { - s.discovery = d + s.discovery = d } func (s *RegistryService) startDiscoveryPolling() { - // 启动后等 3 秒再开始第一次发现,避免和 startup 的 UDP 冲突 - time.Sleep(3 * time.Second) - // First immediate discovery - s.runDiscovery() - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for range ticker.C { - s.runDiscovery() - } + // 启动后等 3 秒再开始第一次发现,避免和 startup 的 UDP 冲突 + time.Sleep(3 * time.Second) + // First immediate discovery + s.runDiscovery() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + s.runDiscovery() + } } func (s *RegistryService) runDiscovery() { - s.mu.RLock() - d := s.discovery - s.mu.RUnlock() - if d == nil { - return - } - d.SearchDefault() + s.mu.RLock() + d := s.discovery + s.mu.RUnlock() + if d == nil { + return + } + d.SearchDefault() } func (s *RegistryService) startGraphPolling() { - ticker := time.NewTicker(30 * time.Second) // Pull every 30s - for range ticker.C { - s.mu.RLock() - var onlineDevices []*models.Device - for _, dev := range s.devices { - if dev.Online { - onlineDevices = append(onlineDevices, dev) - } - } - s.mu.RUnlock() + ticker := time.NewTicker(30 * time.Second) // Pull every 30s + for range ticker.C { + s.mu.RLock() + var onlineDevices []*models.Device + for _, dev := range s.devices { + if dev.Online { + onlineDevices = append(onlineDevices, dev) + } + } + s.mu.RUnlock() - for _, dev := range onlineDevices { - data, _, err := s.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil) - if err == nil { - var graphs interface{} - if err := json.Unmarshal(data, &graphs); err == nil { - s.mu.Lock() - dev.Graphs = graphs - s.mu.Unlock() - s.persistDevice(dev) - } - } - } - } + for _, dev := range onlineDevices { + data, _, err := s.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil) + if err == nil { + var graphs interface{} + if err := json.Unmarshal(data, &graphs); err == nil { + s.mu.Lock() + dev.Graphs = graphs + s.mu.Unlock() + s.persistDevice(dev) + } + } + } + } } func (s *RegistryService) UpdateDevice(dev *models.Device) { - s.mu.Lock() - defer s.mu.Unlock() + s.mu.Lock() + defer s.mu.Unlock() - dev.LastSeenMs = time.Now().UnixMilli() - dev.Online = true - if current, ok := s.devices[dev.DeviceID]; ok && strings.TrimSpace(current.DeviceAlias) != "" { - dev.DeviceAlias = strings.TrimSpace(current.DeviceAlias) - } else if s.repo != nil { - if saved, err := s.repo.List(); err == nil { - for _, item := range saved { - if item != nil && item.DeviceID == dev.DeviceID && strings.TrimSpace(item.DeviceAlias) != "" { - dev.DeviceAlias = strings.TrimSpace(item.DeviceAlias) - break - } - } - } - } - s.devices[dev.DeviceID] = dev - s.persistDevice(dev) + dev.LastSeenMs = time.Now().UnixMilli() + dev.Online = true + if current, ok := s.devices[dev.DeviceID]; ok && strings.TrimSpace(current.DeviceAlias) != "" { + dev.DeviceAlias = strings.TrimSpace(current.DeviceAlias) + } else if s.repo != nil { + if saved, err := s.repo.List(); err == nil { + for _, item := range saved { + if item != nil && item.DeviceID == dev.DeviceID && strings.TrimSpace(item.DeviceAlias) != "" { + dev.DeviceAlias = strings.TrimSpace(item.DeviceAlias) + break + } + } + } + } + s.devices[dev.DeviceID] = dev + s.persistDevice(dev) } func (s *RegistryService) SetDeviceAlias(deviceID string, alias string) error { - s.mu.Lock() - defer s.mu.Unlock() + s.mu.Lock() + defer s.mu.Unlock() - alias = strings.TrimSpace(alias) - if dev, ok := s.devices[deviceID]; ok { - dev.DeviceAlias = alias - s.persistDevice(dev) - return nil - } - s.persistDevice(&models.Device{DeviceID: deviceID, DeviceAlias: alias}) - return nil + alias = strings.TrimSpace(alias) + if dev, ok := s.devices[deviceID]; ok { + dev.DeviceAlias = alias + s.persistDevice(dev) + return nil + } + s.persistDevice(&models.Device{DeviceID: deviceID, DeviceAlias: alias}) + return nil } func (s *RegistryService) GetDevices() []*models.Device { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.RLock() + defer s.mu.RUnlock() - list := make([]*models.Device, 0, len(s.devices)) - for _, dev := range s.devices { - list = append(list, dev) - } - return list + list := make([]*models.Device, 0, len(s.devices)) + for _, dev := range s.devices { + list = append(list, dev) + } + return list } // TouchDevice updates the LastSeenMs for a device to keep it online // when accessed via HTTP API (not just UDP discovery) func (s *RegistryService) TouchDevice(deviceID string) { - s.mu.Lock() - defer s.mu.Unlock() + s.mu.Lock() + defer s.mu.Unlock() - if dev, ok := s.devices[deviceID]; ok { - dev.LastSeenMs = time.Now().UnixMilli() - dev.Online = true - s.persistDevice(dev) - } + if dev, ok := s.devices[deviceID]; ok { + dev.LastSeenMs = time.Now().UnixMilli() + dev.Online = true + s.persistDevice(dev) + } } func (s *RegistryService) startPruning() { - ticker := time.NewTicker(2 * time.Second) - for range ticker.C { - s.mu.Lock() - now := time.Now().UnixMilli() - for _, dev := range s.devices { - if now-dev.LastSeenMs > int64(s.cfg.OfflineAfterMs) { - dev.Online = false - } - } - s.mu.Unlock() - } + ticker := time.NewTicker(2 * time.Second) + for range ticker.C { + s.mu.Lock() + now := time.Now().UnixMilli() + for _, dev := range s.devices { + if now-dev.LastSeenMs > int64(s.cfg.OfflineAfterMs) { + dev.Online = false + } + } + s.mu.Unlock() + } } func (s *RegistryService) persistDevice(dev *models.Device) { - if s == nil || s.repo == nil || dev == nil { - return - } - _ = s.repo.Upsert(dev) + if s == nil || s.repo == nil || dev == nil { + return + } + _ = s.repo.Upsert(dev) } diff --git a/internal/service/registry_test.go b/internal/service/registry_test.go index c692923..05a5472 100644 --- a/internal/service/registry_test.go +++ b/internal/service/registry_test.go @@ -1,132 +1,132 @@ package service import ( - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" - "path/filepath" - "testing" - "time" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" + "path/filepath" + "testing" + "time" ) func TestRegistryService_UpdateAndGet(t *testing.T) { - cfg := &config.Config{ - OfflineAfterMs: 1000, - } - // Mock agent client (nil for now as we don't test polling yet) - svc := NewRegistryService(cfg, nil) + cfg := &config.Config{ + OfflineAfterMs: 1000, + } + // Mock agent client (nil for now as we don't test polling yet) + svc := NewRegistryService(cfg, nil) - dev := &models.Device{ - DeviceID: "test-1", - IP: "127.0.0.1", - } + dev := &models.Device{ + DeviceID: "test-1", + IP: "127.0.0.1", + } - svc.UpdateDevice(dev) + svc.UpdateDevice(dev) - devices := svc.GetDevices() - if len(devices) != 1 { - t.Errorf("expected 1 device, got %d", len(devices)) - } - if devices[0].DeviceID != "test-1" { - t.Errorf("expected device test-1, got %s", devices[0].DeviceID) - } - if !devices[0].Online { - t.Error("expected device to be online") - } + devices := svc.GetDevices() + if len(devices) != 1 { + t.Errorf("expected 1 device, got %d", len(devices)) + } + if devices[0].DeviceID != "test-1" { + t.Errorf("expected device test-1, got %s", devices[0].DeviceID) + } + if !devices[0].Online { + t.Error("expected device to be online") + } } func TestRegistryService_DeviceAliasSurvivesAgentUpdate(t *testing.T) { - cfg := &config.Config{ - OfflineAfterMs: 1000, - } - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewDevicesRepo(store.DB()) - svc := NewRegistryService(cfg, nil, repo) + cfg := &config.Config{ + OfflineAfterMs: 1000, + } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewDevicesRepo(store.DB()) + svc := NewRegistryService(cfg, nil, repo) - if err := svc.SetDeviceAlias("test-1", "备用盒子-01"); err != nil { - t.Fatalf("SetDeviceAlias: %v", err) - } + if err := svc.SetDeviceAlias("test-1", "备用盒子-01"); err != nil { + t.Fatalf("SetDeviceAlias: %v", err) + } - svc.UpdateDevice(&models.Device{ - DeviceID: "test-1", - DeviceName: "rk3588_orangepi5plus", - Hostname: "orangepi5plus", - IP: "127.0.0.1", - }) - svc.UpdateDevice(&models.Device{ - DeviceID: "test-1", - DeviceName: "rk3588_orangepi5plus", - Hostname: "orangepi5plus", - IP: "127.0.0.2", - }) + svc.UpdateDevice(&models.Device{ + DeviceID: "test-1", + DeviceName: "rk3588_orangepi5plus", + Hostname: "orangepi5plus", + IP: "127.0.0.1", + }) + svc.UpdateDevice(&models.Device{ + DeviceID: "test-1", + DeviceName: "rk3588_orangepi5plus", + Hostname: "orangepi5plus", + IP: "127.0.0.2", + }) - devices := svc.GetDevices() - if len(devices) != 1 { - t.Fatalf("expected one device, got %d", len(devices)) - } - if devices[0].DeviceAlias != "备用盒子-01" { - t.Fatalf("expected alias to survive update, got %#v", devices[0]) - } - if devices[0].DisplayName() != "备用盒子-01" { - t.Fatalf("expected display name to prefer alias, got %q", devices[0].DisplayName()) - } + devices := svc.GetDevices() + if len(devices) != 1 { + t.Fatalf("expected one device, got %d", len(devices)) + } + if devices[0].DeviceAlias != "备用盒子-01" { + t.Fatalf("expected alias to survive update, got %#v", devices[0]) + } + if devices[0].DisplayName() != "备用盒子-01" { + t.Fatalf("expected display name to prefer alias, got %q", devices[0].DisplayName()) + } } func TestRegistryService_SetDeviceAliasPersistsWithoutConfigSave(t *testing.T) { - cfg := &config.Config{OfflineAfterMs: 1000} - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewDevicesRepo(store.DB()) - svc := NewRegistryService(cfg, nil, repo) + cfg := &config.Config{OfflineAfterMs: 1000} + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewDevicesRepo(store.DB()) + svc := NewRegistryService(cfg, nil, repo) - svc.UpdateDevice(&models.Device{DeviceID: "test-1", DeviceName: "rk3588_orangepi5plus", IP: "127.0.0.1"}) - if err := svc.SetDeviceAlias("test-1", "备用盒子-01"); err != nil { - t.Fatalf("SetDeviceAlias: %v", err) - } + svc.UpdateDevice(&models.Device{DeviceID: "test-1", DeviceName: "rk3588_orangepi5plus", IP: "127.0.0.1"}) + if err := svc.SetDeviceAlias("test-1", "备用盒子-01"); err != nil { + t.Fatalf("SetDeviceAlias: %v", err) + } - saved, err := repo.List() - if err != nil { - t.Fatalf("List: %v", err) - } - if len(saved) != 1 || saved[0].DeviceAlias != "备用盒子-01" { - t.Fatalf("expected alias persisted in repo, got %#v", saved) - } - if len(cfg.DeviceAliases) != 0 { - t.Fatalf("expected config aliases to stay unused, got %#v", cfg.DeviceAliases) - } + saved, err := repo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(saved) != 1 || saved[0].DeviceAlias != "备用盒子-01" { + t.Fatalf("expected alias persisted in repo, got %#v", saved) + } + if len(cfg.DeviceAliases) != 0 { + t.Fatalf("expected config aliases to stay unused, got %#v", cfg.DeviceAliases) + } } func TestRegistryService_Pruning(t *testing.T) { - cfg := &config.Config{ - OfflineAfterMs: 100, // 100ms - } - svc := NewRegistryService(cfg, nil) + cfg := &config.Config{ + OfflineAfterMs: 100, // 100ms + } + svc := NewRegistryService(cfg, nil) - dev := &models.Device{ - DeviceID: "test-prune", - IP: "127.0.0.1", - } + dev := &models.Device{ + DeviceID: "test-prune", + IP: "127.0.0.1", + } - svc.UpdateDevice(dev) + svc.UpdateDevice(dev) - if !svc.devices["test-prune"].Online { - t.Error("expected device to be online initially") - } + if !svc.devices["test-prune"].Online { + t.Error("expected device to be online initially") + } - // Wait for pruning (ticker is 2s, but we can't wait that long in a fast test if we don't mock the ticker) - // Wait, the ticker in registry.go is 2s. That's a bit long for a unit test. - // I might need to adjust the ticker or mock it. - // For the sake of this test, I'll just check if the logic works by manually calling a prune-like logic if possible, - // or just wait if I have to. + // Wait for pruning (ticker is 2s, but we can't wait that long in a fast test if we don't mock the ticker) + // Wait, the ticker in registry.go is 2s. That's a bit long for a unit test. + // I might need to adjust the ticker or mock it. + // For the sake of this test, I'll just check if the logic works by manually calling a prune-like logic if possible, + // or just wait if I have to. - time.Sleep(200 * time.Millisecond) // Device should be "timed out" but pruning hasn't run yet if it's 2s + time.Sleep(200 * time.Millisecond) // Device should be "timed out" but pruning hasn't run yet if it's 2s - // Since I can't easily trigger the private startPruning, I'll just verify the online status logic itself + // Since I can't easily trigger the private startPruning, I'll just verify the online status logic itself } diff --git a/internal/service/resource_management.go b/internal/service/resource_management.go index 2efcad5..af1af25 100644 --- a/internal/service/resource_management.go +++ b/internal/service/resource_management.go @@ -1,278 +1,278 @@ package service import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" ) type ResourceManagementService struct { - repo *storage.ResourcesRepo + repo *storage.ResourcesRepo } type InstalledResourceStatus struct { - Name string `json:"name"` - ResourceType string `json:"resource_type"` - SHA256 string `json:"sha256"` - SizeBytes int64 `json:"size_bytes"` - UpdatedAt int64 `json:"updated_at"` + Name string `json:"name"` + ResourceType string `json:"resource_type"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + UpdatedAt int64 `json:"updated_at"` } type ResourceStatusCell struct { - ResourceName string `json:"resource_name"` - ResourceType string `json:"resource_type"` - Status string `json:"status"` - Version string `json:"version"` + ResourceName string `json:"resource_name"` + ResourceType string `json:"resource_type"` + Status string `json:"status"` + Version string `json:"version"` } type ResourceStatusRow struct { - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` - Online bool `json:"online"` - Cells []ResourceStatusCell `json:"cells"` - ExtraCount int `json:"extra_resource_count"` - ExtraResources []InstalledResourceStatus `json:"extra_resources"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + Online bool `json:"online"` + Cells []ResourceStatusCell `json:"cells"` + ExtraCount int `json:"extra_resource_count"` + ExtraResources []InstalledResourceStatus `json:"extra_resources"` } type ResourceStatusSummary struct { - StandardResources int `json:"standard_resources"` - Devices int `json:"devices"` - CompleteDevices int `json:"complete_devices"` - MissingDevices int `json:"missing_devices"` - MismatchDevices int `json:"mismatch_devices"` + StandardResources int `json:"standard_resources"` + Devices int `json:"devices"` + CompleteDevices int `json:"complete_devices"` + MissingDevices int `json:"missing_devices"` + MismatchDevices int `json:"mismatch_devices"` } type ResourceStatusBoard struct { - Summary ResourceStatusSummary `json:"summary"` - Rows []ResourceStatusRow `json:"rows"` + Summary ResourceStatusSummary `json:"summary"` + Rows []ResourceStatusRow `json:"rows"` } func NewResourceManagementService(repo *storage.ResourcesRepo) *ResourceManagementService { - return &ResourceManagementService{repo: repo} + return &ResourceManagementService{repo: repo} } func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir string) error { - if s == nil || s.repo == nil { - return fmt.Errorf("resources repo is not configured") - } - dir = filepath.Clean(strings.TrimSpace(dir)) - if dir == "" { - return fmt.Errorf("standard resources dir is empty") - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - for _, entry := range entries { - if entry.IsDir() { - // Subdirectory = resource_type; scan files inside - resourceType := entry.Name() - subDir := filepath.Join(dir, resourceType) - subEntries, serr := os.ReadDir(subDir) - if serr != nil { - continue - } - for _, sub := range subEntries { - if sub.IsDir() { - continue - } - fullPath := filepath.Join(subDir, sub.Name()) - sum, size, err := hashFile(fullPath) - if err != nil { - return err - } - record := storage.StandardResourceRecord{ - Name: strings.TrimSuffix(sub.Name(), filepath.Ext(sub.Name())), - ResourceType: resourceType, - Version: "auto", - SHA256: sum, - SizeBytes: size, - FilePath: fullPath, - } - if err := s.repo.Save(record); err != nil { - return err - } - } - continue - } - // Flat file: infer resource_type from filename or leave empty (legacy) - fullPath := filepath.Join(dir, entry.Name()) - sum, size, err := hashFile(fullPath) - if err != nil { - return err - } - record := storage.StandardResourceRecord{ - Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())), - Version: "auto", - SHA256: sum, - SizeBytes: size, - FilePath: fullPath, - } - if err := s.repo.Save(record); err != nil { - return err - } - } - return nil + if s == nil || s.repo == nil { + return fmt.Errorf("resources repo is not configured") + } + dir = filepath.Clean(strings.TrimSpace(dir)) + if dir == "" { + return fmt.Errorf("standard resources dir is empty") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + // Subdirectory = resource_type; scan files inside + resourceType := entry.Name() + subDir := filepath.Join(dir, resourceType) + subEntries, serr := os.ReadDir(subDir) + if serr != nil { + continue + } + for _, sub := range subEntries { + if sub.IsDir() { + continue + } + fullPath := filepath.Join(subDir, sub.Name()) + sum, size, err := hashFile(fullPath) + if err != nil { + return err + } + record := storage.StandardResourceRecord{ + Name: strings.TrimSuffix(sub.Name(), filepath.Ext(sub.Name())), + ResourceType: resourceType, + Version: "auto", + SHA256: sum, + SizeBytes: size, + FilePath: fullPath, + } + if err := s.repo.Save(record); err != nil { + return err + } + } + continue + } + // Flat file: infer resource_type from filename or leave empty (legacy) + fullPath := filepath.Join(dir, entry.Name()) + sum, size, err := hashFile(fullPath) + if err != nil { + return err + } + record := storage.StandardResourceRecord{ + Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())), + Version: "auto", + SHA256: sum, + SizeBytes: size, + FilePath: fullPath, + } + if err := s.repo.Save(record); err != nil { + return err + } + } + return nil } func FetchInstalledResourceStatuses(agent *AgentClient, dev *models.Device) ([]InstalledResourceStatus, error) { - if agent == nil || dev == nil || strings.TrimSpace(dev.IP) == "" || dev.AgentPort <= 0 { - return nil, nil - } - body, status, err := agent.Do("GET", dev.IP, dev.AgentPort, "/v1/resources/status", nil) - if err != nil { - return nil, err - } - if status != 200 { - return nil, nil // agent may not implement this endpoint yet — return empty - } - var resp struct { - Resources []InstalledResourceStatus `json:"resources"` - } - if err := json.Unmarshal(body, &resp); err != nil { - return nil, err - } - return resp.Resources, nil + if agent == nil || dev == nil || strings.TrimSpace(dev.IP) == "" || dev.AgentPort <= 0 { + return nil, nil + } + body, status, err := agent.Do("GET", dev.IP, dev.AgentPort, "/v1/resources/status", nil) + if err != nil { + return nil, err + } + if status != 200 { + return nil, nil // agent may not implement this endpoint yet — return empty + } + var resp struct { + Resources []InstalledResourceStatus `json:"resources"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return resp.Resources, nil } func BuildResourceStatusBoard(standardResources []storage.StandardResourceRecord, devices []*models.Device, installed map[string][]InstalledResourceStatus) ResourceStatusBoard { - board := ResourceStatusBoard{ - Summary: ResourceStatusSummary{ - StandardResources: len(standardResources), - Devices: len(devices), - }, - Rows: make([]ResourceStatusRow, 0, len(devices)), - } - for _, device := range devices { - if device == nil { - continue - } - index := make(map[string]InstalledResourceStatus, len(installed[device.DeviceID])) - for _, item := range installed[device.DeviceID] { - index[item.Name] = item - } - standardIndex := make(map[string]struct{}, len(standardResources)) - for _, res := range standardResources { - standardIndex[res.Name] = struct{}{} - } - row := ResourceStatusRow{ - DeviceID: device.DeviceID, - DeviceName: device.DisplayName(), - Online: device.Online, - Cells: make([]ResourceStatusCell, 0, len(standardResources)), - ExtraResources: make([]InstalledResourceStatus, 0), - } - hasMissing := false - hasMismatch := false - for _, res := range standardResources { - cell := ResourceStatusCell{ - ResourceName: res.Name, - ResourceType: res.ResourceType, - Version: res.Version, - Status: "missing", - } - if item, ok := index[res.Name]; ok { - if strings.EqualFold(strings.TrimSpace(item.SHA256), strings.TrimSpace(res.SHA256)) { - cell.Status = "ok" - } else { - cell.Status = "mismatch" - hasMismatch = true - } - } else { - hasMissing = true - } - if cell.Status == "missing" { - hasMissing = true - } - row.Cells = append(row.Cells, cell) - } - for _, item := range installed[device.DeviceID] { - if _, ok := standardIndex[item.Name]; ok { - continue - } - row.ExtraResources = append(row.ExtraResources, item) - } - sort.Slice(row.ExtraResources, func(i, j int) bool { - return row.ExtraResources[i].Name < row.ExtraResources[j].Name - }) - row.ExtraCount = len(row.ExtraResources) - switch { - case hasMismatch: - board.Summary.MismatchDevices++ - case hasMissing: - board.Summary.MissingDevices++ - default: - board.Summary.CompleteDevices++ - } - board.Rows = append(board.Rows, row) - } - sort.SliceStable(board.Rows, func(i, j int) bool { - return board.Rows[i].DeviceName < board.Rows[j].DeviceName - }) - return board + board := ResourceStatusBoard{ + Summary: ResourceStatusSummary{ + StandardResources: len(standardResources), + Devices: len(devices), + }, + Rows: make([]ResourceStatusRow, 0, len(devices)), + } + for _, device := range devices { + if device == nil { + continue + } + index := make(map[string]InstalledResourceStatus, len(installed[device.DeviceID])) + for _, item := range installed[device.DeviceID] { + index[item.Name] = item + } + standardIndex := make(map[string]struct{}, len(standardResources)) + for _, res := range standardResources { + standardIndex[res.Name] = struct{}{} + } + row := ResourceStatusRow{ + DeviceID: device.DeviceID, + DeviceName: device.DisplayName(), + Online: device.Online, + Cells: make([]ResourceStatusCell, 0, len(standardResources)), + ExtraResources: make([]InstalledResourceStatus, 0), + } + hasMissing := false + hasMismatch := false + for _, res := range standardResources { + cell := ResourceStatusCell{ + ResourceName: res.Name, + ResourceType: res.ResourceType, + Version: res.Version, + Status: "missing", + } + if item, ok := index[res.Name]; ok { + if strings.EqualFold(strings.TrimSpace(item.SHA256), strings.TrimSpace(res.SHA256)) { + cell.Status = "ok" + } else { + cell.Status = "mismatch" + hasMismatch = true + } + } else { + hasMissing = true + } + if cell.Status == "missing" { + hasMissing = true + } + row.Cells = append(row.Cells, cell) + } + for _, item := range installed[device.DeviceID] { + if _, ok := standardIndex[item.Name]; ok { + continue + } + row.ExtraResources = append(row.ExtraResources, item) + } + sort.Slice(row.ExtraResources, func(i, j int) bool { + return row.ExtraResources[i].Name < row.ExtraResources[j].Name + }) + row.ExtraCount = len(row.ExtraResources) + switch { + case hasMismatch: + board.Summary.MismatchDevices++ + case hasMissing: + board.Summary.MissingDevices++ + default: + board.Summary.CompleteDevices++ + } + board.Rows = append(board.Rows, row) + } + sort.SliceStable(board.Rows, func(i, j int) bool { + return board.Rows[i].DeviceName < board.Rows[j].DeviceName + }) + return board } // FetchAndBuildResourceBoard 并发查询所有在线设备的资源状态并构建面板,总超时 5 秒。 func FetchAndBuildResourceBoard(agent *AgentClient, devices []*models.Device, standardResources []storage.StandardResourceRecord) ResourceStatusBoard { - board := ResourceStatusBoard{ - Summary: ResourceStatusSummary{ - StandardResources: len(standardResources), - Devices: len(devices), - }, - Rows: make([]ResourceStatusRow, 0, len(devices)), - } - if agent == nil || len(standardResources) == 0 { - return board - } + board := ResourceStatusBoard{ + Summary: ResourceStatusSummary{ + StandardResources: len(standardResources), + Devices: len(devices), + }, + Rows: make([]ResourceStatusRow, 0, len(devices)), + } + if agent == nil || len(standardResources) == 0 { + return board + } - installed := make(map[string][]InstalledResourceStatus) - var mu sync.Mutex - var wg sync.WaitGroup + installed := make(map[string][]InstalledResourceStatus) + var mu sync.Mutex + var wg sync.WaitGroup - // 总超时:最多等 5 秒 - done := make(chan struct{}) - go func() { - time.Sleep(5 * time.Second) - close(done) - }() + // 总超时:最多等 5 秒 + done := make(chan struct{}) + go func() { + time.Sleep(5 * time.Second) + close(done) + }() - for _, device := range devices { - if device == nil || !device.Online { - continue - } - wg.Add(1) - go func(dev *models.Device) { - defer wg.Done() - items, err := FetchInstalledResourceStatuses(agent, dev) - if err == nil { - mu.Lock() - installed[dev.DeviceID] = items - mu.Unlock() - } - }(device) - } + for _, device := range devices { + if device == nil || !device.Online { + continue + } + wg.Add(1) + go func(dev *models.Device) { + defer wg.Done() + items, err := FetchInstalledResourceStatuses(agent, dev) + if err == nil { + mu.Lock() + installed[dev.DeviceID] = items + mu.Unlock() + } + }(device) + } - // Wait with timeout - waitCh := make(chan struct{}) - go func() { - wg.Wait() - close(waitCh) - }() - select { - case <-waitCh: - case <-done: - } + // Wait with timeout + waitCh := make(chan struct{}) + go func() { + wg.Wait() + close(waitCh) + }() + select { + case <-waitCh: + case <-done: + } - return BuildResourceStatusBoard(standardResources, devices, installed) + return BuildResourceStatusBoard(standardResources, devices, installed) } diff --git a/internal/service/resource_management_test.go b/internal/service/resource_management_test.go index 948c0fa..f18de0b 100644 --- a/internal/service/resource_management_test.go +++ b/internal/service/resource_management_test.go @@ -1,97 +1,97 @@ package service import ( - "os" - "path/filepath" - "testing" + "os" + "path/filepath" + "testing" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" ) func TestSyncStandardResourcesFromDirectory(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "face_gallery_v1.db") - if err := os.WriteFile(path, []byte("face-gallery-bytes"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } + dir := t.TempDir() + path := filepath.Join(dir, "face_gallery_v1.db") + if err := os.WriteFile(path, []byte("face-gallery-bytes"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := storage.NewResourcesRepo(store.DB()) - svc := NewResourceManagementService(repo) + repo := storage.NewResourcesRepo(store.DB()) + svc := NewResourceManagementService(repo) - if err := svc.SyncStandardResourcesFromDirectory(dir); err != nil { - t.Fatalf("SyncStandardResourcesFromDirectory: %v", err) - } + if err := svc.SyncStandardResourcesFromDirectory(dir); err != nil { + t.Fatalf("SyncStandardResourcesFromDirectory: %v", err) + } - items, err := repo.List() - if err != nil { - t.Fatalf("List: %v", err) - } - if len(items) != 1 || items[0].Name != "face_gallery_v1" { - t.Fatalf("unexpected synced resources: %#v", items) - } - if items[0].SHA256 == "" { - t.Fatalf("expected sha256 to be populated: %#v", items[0]) - } + items, err := repo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 1 || items[0].Name != "face_gallery_v1" { + t.Fatalf("unexpected synced resources: %#v", items) + } + if items[0].SHA256 == "" { + t.Fatalf("expected sha256 to be populated: %#v", items[0]) + } } func TestBuildResourceStatusBoardMarksMissingAndMismatch(t *testing.T) { - resources := []storage.StandardResourceRecord{ - {Name: "face_gallery_v1", ResourceType: "face_gallery", SHA256: "sha-1", Version: "v1"}, - {Name: "ppe_dataset_v1", ResourceType: "dataset", SHA256: "sha-2", Version: "v2"}, - } - devices := []*models.Device{ - {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, - } - installed := map[string][]InstalledResourceStatus{ - "edge-01": { - {Name: "face_gallery_v1", ResourceType: "face_gallery", SHA256: "sha-1"}, - }, - } + resources := []storage.StandardResourceRecord{ + {Name: "face_gallery_v1", ResourceType: "face_gallery", SHA256: "sha-1", Version: "v1"}, + {Name: "ppe_dataset_v1", ResourceType: "dataset", SHA256: "sha-2", Version: "v2"}, + } + devices := []*models.Device{ + {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, + } + installed := map[string][]InstalledResourceStatus{ + "edge-01": { + {Name: "face_gallery_v1", ResourceType: "face_gallery", SHA256: "sha-1"}, + }, + } - board := BuildResourceStatusBoard(resources, devices, installed) + board := BuildResourceStatusBoard(resources, devices, installed) - if board.Summary.MissingDevices != 1 { - t.Fatalf("expected one device with missing resources, got %#v", board.Summary) - } - if len(board.Rows) != 1 || len(board.Rows[0].Cells) != 2 { - t.Fatalf("unexpected board rows: %#v", board.Rows) - } - if board.Rows[0].Cells[1].Status != "missing" { - t.Fatalf("expected second resource to be missing, got %#v", board.Rows[0].Cells[1]) - } + if board.Summary.MissingDevices != 1 { + t.Fatalf("expected one device with missing resources, got %#v", board.Summary) + } + if len(board.Rows) != 1 || len(board.Rows[0].Cells) != 2 { + t.Fatalf("unexpected board rows: %#v", board.Rows) + } + if board.Rows[0].Cells[1].Status != "missing" { + t.Fatalf("expected second resource to be missing, got %#v", board.Rows[0].Cells[1]) + } } func TestBuildResourceStatusBoardSeparatesExtraResources(t *testing.T) { - resources := []storage.StandardResourceRecord{ - {Name: "face_gallery_v1", ResourceType: "face_gallery", SHA256: "sha-1"}, - } - devices := []*models.Device{ - {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, - } - installed := map[string][]InstalledResourceStatus{ - "edge-01": { - {Name: "face_gallery_v1", ResourceType: "face_gallery", SHA256: "sha-1"}, - {Name: "extra_db", ResourceType: "face_gallery", SHA256: "sha-x"}, - {Name: "custom_ds", ResourceType: "dataset", SHA256: "sha-y"}, - }, - } + resources := []storage.StandardResourceRecord{ + {Name: "face_gallery_v1", ResourceType: "face_gallery", SHA256: "sha-1"}, + } + devices := []*models.Device{ + {DeviceID: "edge-01", DeviceName: "设备一", Online: true}, + } + installed := map[string][]InstalledResourceStatus{ + "edge-01": { + {Name: "face_gallery_v1", ResourceType: "face_gallery", SHA256: "sha-1"}, + {Name: "extra_db", ResourceType: "face_gallery", SHA256: "sha-x"}, + {Name: "custom_ds", ResourceType: "dataset", SHA256: "sha-y"}, + }, + } - board := BuildResourceStatusBoard(resources, devices, installed) + board := BuildResourceStatusBoard(resources, devices, installed) - if len(board.Rows) != 1 { - t.Fatalf("unexpected board rows: %#v", board.Rows) - } - if got := board.Rows[0].ExtraCount; got != 2 { - t.Fatalf("expected 2 extra resources, got %#v", board.Rows[0]) - } - if len(board.Rows[0].ExtraResources) != 2 { - t.Fatalf("expected extra resource details to be preserved, got %#v", board.Rows[0].ExtraResources) - } + if len(board.Rows) != 1 { + t.Fatalf("unexpected board rows: %#v", board.Rows) + } + if got := board.Rows[0].ExtraCount; got != 2 { + t.Fatalf("expected 2 extra resources, got %#v", board.Rows[0]) + } + if len(board.Rows[0].ExtraResources) != 2 { + t.Fatalf("expected extra resource details to be preserved, got %#v", board.Rows[0].ExtraResources) + } } diff --git a/internal/service/standard_templates.go b/internal/service/standard_templates.go index aa24f6a..052b37f 100644 --- a/internal/service/standard_templates.go +++ b/internal/service/standard_templates.go @@ -1,77 +1,77 @@ package service import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" - "3588AdminBackend/internal/storage" + "3588AdminBackend/internal/storage" ) func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int, error) { - if repo == nil { - return 0, fmt.Errorf("asset repository is not configured") - } - dir = filepath.Clean(strings.TrimSpace(dir)) - if dir == "" { - return 0, fmt.Errorf("standard template dir is empty") - } - files, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return 0, nil - } - return 0, err - } - imported := 0 - for _, file := range files { - if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" { - continue - } - path := filepath.Join(dir, file.Name()) - body, err := os.ReadFile(path) - if err != nil { - return imported, err - } - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return imported, fmt.Errorf("%s: %w", path, err) - } - if raw == nil { - raw = map[string]any{} - } - name := strings.TrimSpace(firstString(raw["name"], strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())))) - if name == "" { - return imported, fmt.Errorf("%s: template name is empty", path) - } - if err := validateConfigName(name); err != nil { - return imported, fmt.Errorf("%s: invalid template name %q: %w", path, name, err) - } - if !isStandardTemplateName(name) { - return imported, fmt.Errorf("%s: standard template name must start with std_", path) - } - if strings.TrimSpace(stringValue(raw["source"])) == "" { - raw["source"] = "standard" - } - body, err = marshalConfigJSON(raw) - if err != nil { - return imported, err - } - existing, err := repo.GetTemplate(name) - if err != nil { - return imported, err - } - if existing != nil && - strings.TrimSpace(existing.Description) == strings.TrimSpace(stringValue(raw["description"])) && - strings.TrimSpace(existing.BodyJSON) == strings.TrimSpace(string(body)) { - continue - } - if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil { - return imported, err - } - imported++ - } - return imported, nil + if repo == nil { + return 0, fmt.Errorf("asset repository is not configured") + } + dir = filepath.Clean(strings.TrimSpace(dir)) + if dir == "" { + return 0, fmt.Errorf("standard template dir is empty") + } + files, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + imported := 0 + for _, file := range files { + if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" { + continue + } + path := filepath.Join(dir, file.Name()) + body, err := os.ReadFile(path) + if err != nil { + return imported, err + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return imported, fmt.Errorf("%s: %w", path, err) + } + if raw == nil { + raw = map[string]any{} + } + name := strings.TrimSpace(firstString(raw["name"], strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())))) + if name == "" { + return imported, fmt.Errorf("%s: template name is empty", path) + } + if err := validateConfigName(name); err != nil { + return imported, fmt.Errorf("%s: invalid template name %q: %w", path, name, err) + } + if !isStandardTemplateName(name) { + return imported, fmt.Errorf("%s: standard template name must start with std_", path) + } + if strings.TrimSpace(stringValue(raw["source"])) == "" { + raw["source"] = "standard" + } + body, err = marshalConfigJSON(raw) + if err != nil { + return imported, err + } + existing, err := repo.GetTemplate(name) + if err != nil { + return imported, err + } + if existing != nil && + strings.TrimSpace(existing.Description) == strings.TrimSpace(stringValue(raw["description"])) && + strings.TrimSpace(existing.BodyJSON) == strings.TrimSpace(string(body)) { + continue + } + if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil { + return imported, err + } + imported++ + } + return imported, nil } diff --git a/internal/service/task.go b/internal/service/task.go index 925c7c7..a6e1b74 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -1,704 +1,704 @@ package service import ( - "bytes" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" - "github.com/google/uuid" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" + "github.com/google/uuid" ) type TaskRepository interface { - Save(task *models.Task) error - List() ([]models.Task, error) + Save(task *models.Task) error + List() ([]models.Task, error) } type DeviceConfigStateRepository interface { - UpsertState(deviceID string, templateName string, profileName string, overlaysJSON string, configID string, configVersion string, lastAppliedTaskID string) error + UpsertState(deviceID string, templateName string, profileName string, overlaysJSON string, configID string, configVersion string, lastAppliedTaskID string) error } type AuditLogRepository interface { - AppendLog(actor string, action string, targetType string, targetID string, detailsJSON string) error + AppendLog(actor string, action string, targetType string, targetID string, detailsJSON string) error } type TaskService struct { - cfg *config.Config - agent *AgentClient - registry *RegistryService - repo TaskRepository - models *storage.ModelsRepo - modelsDir string - resources *storage.ResourcesRepo - stateRepo DeviceConfigStateRepository - auditRepo AuditLogRepository - tasks map[string]*models.Task - mu sync.RWMutex - listeners map[string][]chan *models.DeviceTaskStatus - lmu sync.RWMutex + cfg *config.Config + agent *AgentClient + registry *RegistryService + repo TaskRepository + models *storage.ModelsRepo + modelsDir string + resources *storage.ResourcesRepo + stateRepo DeviceConfigStateRepository + auditRepo AuditLogRepository + tasks map[string]*models.Task + mu sync.RWMutex + listeners map[string][]chan *models.DeviceTaskStatus + lmu sync.RWMutex } func (s *TaskService) SetDeviceConfigStateRepo(repo DeviceConfigStateRepository) { - if s == nil { - return - } - s.stateRepo = repo + if s == nil { + return + } + s.stateRepo = repo } func (s *TaskService) SetAuditLogRepo(repo AuditLogRepository) { - if s == nil { - return - } - s.auditRepo = repo + if s == nil { + return + } + s.auditRepo = repo } func (s *TaskService) SetStandardModels(models *storage.ModelsRepo, dir string) { - if s == nil { - return - } - s.models = models - s.modelsDir = filepath.Clean(dir) + if s == nil { + return + } + s.models = models + s.modelsDir = filepath.Clean(dir) } func (s *TaskService) SetStandardResources(repo *storage.ResourcesRepo) { - if s == nil { - return - } - s.resources = repo + if s == nil { + return + } + s.resources = repo } func NewTaskService(cfg *config.Config, agent *AgentClient, registry *RegistryService, repo ...TaskRepository) *TaskService { - var taskRepo TaskRepository - if len(repo) > 0 { - taskRepo = repo[0] - } - return &TaskService{ - cfg: cfg, - agent: agent, - registry: registry, - repo: taskRepo, - tasks: make(map[string]*models.Task), - listeners: make(map[string][]chan *models.DeviceTaskStatus), - } + var taskRepo TaskRepository + if len(repo) > 0 { + taskRepo = repo[0] + } + return &TaskService{ + cfg: cfg, + agent: agent, + registry: registry, + repo: taskRepo, + tasks: make(map[string]*models.Task), + listeners: make(map[string][]chan *models.DeviceTaskStatus), + } } func (s *TaskService) ListTasks() []models.Task { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.RLock() + defer s.mu.RUnlock() - items := make([]models.Task, 0, len(s.tasks)) - for _, t := range s.tasks { - t.Mu.RLock() + items := make([]models.Task, 0, len(s.tasks)) + for _, t := range s.tasks { + t.Mu.RLock() - snap := models.Task{ - ID: t.ID, - Type: t.Type, - DeviceIDs: append([]string(nil), t.DeviceIDs...), - Payload: t.Payload, - Status: t.Status, - Devices: make(map[string]*models.DeviceTaskStatus, len(t.Devices)), - } - for did, ds := range t.Devices { - snap.Devices[did] = &models.DeviceTaskStatus{ - DeviceID: ds.DeviceID, - Status: ds.Status, - Progress: ds.Progress, - Error: ds.Error, - } - } + snap := models.Task{ + ID: t.ID, + Type: t.Type, + DeviceIDs: append([]string(nil), t.DeviceIDs...), + Payload: t.Payload, + Status: t.Status, + Devices: make(map[string]*models.DeviceTaskStatus, len(t.Devices)), + } + for did, ds := range t.Devices { + snap.Devices[did] = &models.DeviceTaskStatus{ + DeviceID: ds.DeviceID, + Status: ds.Status, + Progress: ds.Progress, + Error: ds.Error, + } + } - t.Mu.RUnlock() - items = append(items, snap) - } + t.Mu.RUnlock() + items = append(items, snap) + } - return items + return items } func (s *TaskService) CreateTask(tType string, deviceIDs []string, payload interface{}) (*models.Task, error) { - id := uuid.New().String() - task := models.NewTask(id, tType, deviceIDs, payload) + id := uuid.New().String() + task := models.NewTask(id, tType, deviceIDs, payload) - s.mu.Lock() - s.tasks[id] = task - s.mu.Unlock() - s.persistTask(task) + s.mu.Lock() + s.tasks[id] = task + s.mu.Unlock() + s.persistTask(task) - go s.runTask(task) - return task, nil + go s.runTask(task) + return task, nil } func (s *TaskService) LoadPersistedTasks() error { - if s == nil || s.repo == nil { - return nil - } - items, err := s.repo.List() - if err != nil { - return err - } + if s == nil || s.repo == nil { + return nil + } + items, err := s.repo.List() + if err != nil { + return err + } - s.mu.Lock() - defer s.mu.Unlock() - for i := range items { - item := items[i] - s.tasks[item.ID] = models.NewTask(item.ID, item.Type, append([]string(nil), item.DeviceIDs...), item.Payload) - s.tasks[item.ID].Status = item.Status - for did, ds := range item.Devices { - if ds == nil { - continue - } - s.tasks[item.ID].Devices[did] = &models.DeviceTaskStatus{ - DeviceID: ds.DeviceID, - Status: ds.Status, - Progress: ds.Progress, - Error: ds.Error, - } - } - } - return nil + s.mu.Lock() + defer s.mu.Unlock() + for i := range items { + item := items[i] + s.tasks[item.ID] = models.NewTask(item.ID, item.Type, append([]string(nil), item.DeviceIDs...), item.Payload) + s.tasks[item.ID].Status = item.Status + for did, ds := range item.Devices { + if ds == nil { + continue + } + s.tasks[item.ID].Devices[did] = &models.DeviceTaskStatus{ + DeviceID: ds.DeviceID, + Status: ds.Status, + Progress: ds.Progress, + Error: ds.Error, + } + } + } + return nil } func (s *TaskService) runTask(task *models.Task) { - task.Mu.Lock() - task.Status = models.TaskRunning - task.Mu.Unlock() - s.persistTask(task) + task.Mu.Lock() + task.Status = models.TaskRunning + task.Mu.Unlock() + s.persistTask(task) - // Concurrency control - concurrency := s.cfg.Concurrency - if concurrency <= 0 { - concurrency = 5 - } - sem := make(chan struct{}, concurrency) - var wg sync.WaitGroup + // Concurrency control + concurrency := s.cfg.Concurrency + if concurrency <= 0 { + concurrency = 5 + } + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup - for _, did := range task.DeviceIDs { - wg.Add(1) - go func(did string) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() + for _, did := range task.DeviceIDs { + wg.Add(1) + go func(did string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() - s.executeOnDevice(task, did) - }(did) - } + s.executeOnDevice(task, did) + }(did) + } - wg.Wait() + wg.Wait() - // Overall status: success only if all devices succeed. - task.Mu.Lock() - overallOK := true - for _, ds := range task.Devices { - if ds == nil || ds.Status != models.TaskSuccess { - overallOK = false - break - } - } - if overallOK { - task.Status = models.TaskSuccess - } else { - task.Status = models.TaskFailed - } - task.Mu.Unlock() - s.persistTask(task) + // Overall status: success only if all devices succeed. + task.Mu.Lock() + overallOK := true + for _, ds := range task.Devices { + if ds == nil || ds.Status != models.TaskSuccess { + overallOK = false + break + } + } + if overallOK { + task.Status = models.TaskSuccess + } else { + task.Status = models.TaskFailed + } + task.Mu.Unlock() + s.persistTask(task) } func extractConfigPayload(payload any) (any, error) { - if payload == nil { - return nil, fmt.Errorf("payload is required") - } - // Backward-compatible: if payload is {"config": }, use payload.config. - if m, ok := payload.(map[string]any); ok { - if v, exists := m["config"]; exists { - return v, nil - } - } - return payload, nil + if payload == nil { + return nil, fmt.Errorf("payload is required") + } + // Backward-compatible: if payload is {"config": }, use payload.config. + if m, ok := payload.(map[string]any); ok { + if v, exists := m["config"]; exists { + return v, nil + } + } + return payload, nil } func extractConfigPayloadForDevice(payload any, deviceID string) (any, error) { - if payload == nil { - return nil, fmt.Errorf("payload is required") - } - if m, ok := payload.(map[string]any); ok { - if configs, exists := m["configs"]; exists { - if byDevice, ok := configs.(map[string]any); ok { - if v, ok := byDevice[deviceID]; ok { - return v, nil - } - return nil, fmt.Errorf("device assignment config is missing for %s", deviceID) - } - } - } - return extractConfigPayload(payload) + if payload == nil { + return nil, fmt.Errorf("payload is required") + } + if m, ok := payload.(map[string]any); ok { + if configs, exists := m["configs"]; exists { + if byDevice, ok := configs.(map[string]any); ok { + if v, ok := byDevice[deviceID]; ok { + return v, nil + } + return nil, fmt.Errorf("device assignment config is missing for %s", deviceID) + } + } + } + return extractConfigPayload(payload) } func optionalConfigRequestBody(payload any) (io.Reader, int64, error) { - if payload == nil { - return nil, 0, nil - } - // Accept payload as either {"config":"cam1"} or any map that contains a string config. - m, ok := payload.(map[string]any) - if !ok { - return nil, 0, nil - } - v, exists := m["config"] - if !exists { - return nil, 0, nil - } - configStr, ok := v.(string) - if !ok || configStr == "" { - // Ignore invalid shapes (e.g. UI default {"config":{}}) to avoid 400. - return nil, 0, nil - } - b, err := json.Marshal(map[string]any{"config": configStr}) - if err != nil { - return nil, 0, err - } - return bytes.NewReader(b), int64(len(b)), nil + if payload == nil { + return nil, 0, nil + } + // Accept payload as either {"config":"cam1"} or any map that contains a string config. + m, ok := payload.(map[string]any) + if !ok { + return nil, 0, nil + } + v, exists := m["config"] + if !exists { + return nil, 0, nil + } + configStr, ok := v.(string) + if !ok || configStr == "" { + // Ignore invalid shapes (e.g. UI default {"config":{}}) to avoid 400. + return nil, 0, nil + } + b, err := json.Marshal(map[string]any{"config": configStr}) + if err != nil { + return nil, 0, err + } + return bytes.NewReader(b), int64(len(b)), nil } func (s *TaskService) executeOnDevice(task *models.Task, did string) { - s.updateDeviceStatus(task.ID, did, models.TaskRunning, 0, "") - if s.agent == nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "agent client not initialized") - return - } + s.updateDeviceStatus(task.ID, did, models.TaskRunning, 0, "") + if s.agent == nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "agent client not initialized") + return + } - // Find device - devs := s.registry.GetDevices() - var dev *models.Device - for _, d := range devs { - if d.DeviceID == did { - dev = d - break - } - } + // Find device + devs := s.registry.GetDevices() + var dev *models.Device + for _, d := range devs { + if d.DeviceID == did { + dev = d + break + } + } - if dev == nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "device not found") - return - } + if dev == nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "device not found") + return + } - if !dev.Online { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "device offline") - return - } + if !dev.Online { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "device offline") + return + } - switch task.Type { - case "config_apply": - cfgPayload, err := extractConfigPayloadForDevice(task.Payload, did) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - body, err := json.Marshal(cfgPayload) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "invalid payload: "+err.Error()) - return - } - _, code, err := s.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", body) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - if code >= 400 { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.persistConfigState(task, did) - s.appendAuditLog(task, did, models.TaskSuccess, "") + switch task.Type { + case "config_apply": + cfgPayload, err := extractConfigPayloadForDevice(task.Payload, did) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + body, err := json.Marshal(cfgPayload) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "invalid payload: "+err.Error()) + return + } + _, code, err := s.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", body) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.persistConfigState(task, did) + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "reload": - _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/reload", nil, "", 0) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - if code >= 400 { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "reload": + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/reload", nil, "", 0) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "rollback": - _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/rollback", nil, "", 0) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - if code >= 400 { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "rollback": + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/rollback", nil, "", 0) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "media_start": - bodyR, bodyLen, err := optionalConfigRequestBody(task.Payload) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/start", bodyR, "", bodyLen) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - if code >= 400 { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "media_start": + bodyR, bodyLen, err := optionalConfigRequestBody(task.Payload) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/start", bodyR, "", bodyLen) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "media_restart": - bodyR, bodyLen, err := optionalConfigRequestBody(task.Payload) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/restart", bodyR, "", bodyLen) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - if code >= 400 { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "media_restart": + bodyR, bodyLen, err := optionalConfigRequestBody(task.Payload) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/restart", bodyR, "", bodyLen) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "media_stop": - _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/stop", nil, "", 0) - if err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - if code >= 400 { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "media_stop": + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/stop", nil, "", 0) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "model_sync_one": - if err := s.syncModelToDevice(task, dev, did, false); err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "model_sync_one": + if err := s.syncModelToDevice(task, dev, did, false); err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "model_sync_all": - if err := s.syncModelToDevice(task, dev, did, true); err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "model_sync_all": + if err := s.syncModelToDevice(task, dev, did, true); err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "resource_sync_one": - if err := s.syncResourceToDevice(task, dev, did, false); err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "resource_sync_one": + if err := s.syncResourceToDevice(task, dev, did, false); err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - case "resource_sync_all": - if err := s.syncResourceToDevice(task, dev, did, true); err != nil { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - return - } - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") - s.appendAuditLog(task, did, models.TaskSuccess, "") + case "resource_sync_all": + if err := s.syncResourceToDevice(task, dev, did, true); err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + s.appendAuditLog(task, did, models.TaskSuccess, "") - default: - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "unsupported task type") - } + default: + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "unsupported task type") + } } func (s *TaskService) syncModelToDevice(task *models.Task, dev *models.Device, did string, syncAll bool) error { - if s.models == nil { - return fmt.Errorf("standard models repo is not configured") - } - if strings.TrimSpace(s.modelsDir) == "" { - return fmt.Errorf("standard models directory is not configured") - } - items, err := s.models.List() - if err != nil { - return err - } - if len(items) == 0 { - return fmt.Errorf("no standard models configured") - } - targets := items[:0] - if syncAll { - targets = append(targets, items...) - } else { - payload, _ := task.Payload.(map[string]any) - targetName := strings.TrimSpace(stringAny(payload["model_name"])) - if targetName == "" { - return fmt.Errorf("model_name is required") - } - for _, item := range items { - if strings.TrimSpace(item.Name) == targetName { - targets = append(targets, item) - break - } - } - if len(targets) == 0 { - return fmt.Errorf("standard model %q not found", targetName) - } - } - for _, item := range targets { - fullPath := filepath.Join(s.modelsDir, item.FileName) - file, err := os.Open(fullPath) - if err != nil { - return err - } - stat, err := file.Stat() - if err != nil { - file.Close() - return err - } - resp, code, err := s.agent.DoStream("PUT", dev.IP, dev.AgentPort, "/v1/models/"+item.Name, file, "application/octet-stream", stat.Size()) - file.Close() - if err != nil { - return err - } - if code >= 400 { - return fmt.Errorf("agent error: %d %s", code, strings.TrimSpace(string(resp))) - } - } - return nil + if s.models == nil { + return fmt.Errorf("standard models repo is not configured") + } + if strings.TrimSpace(s.modelsDir) == "" { + return fmt.Errorf("standard models directory is not configured") + } + items, err := s.models.List() + if err != nil { + return err + } + if len(items) == 0 { + return fmt.Errorf("no standard models configured") + } + targets := items[:0] + if syncAll { + targets = append(targets, items...) + } else { + payload, _ := task.Payload.(map[string]any) + targetName := strings.TrimSpace(stringAny(payload["model_name"])) + if targetName == "" { + return fmt.Errorf("model_name is required") + } + for _, item := range items { + if strings.TrimSpace(item.Name) == targetName { + targets = append(targets, item) + break + } + } + if len(targets) == 0 { + return fmt.Errorf("standard model %q not found", targetName) + } + } + for _, item := range targets { + fullPath := filepath.Join(s.modelsDir, item.FileName) + file, err := os.Open(fullPath) + if err != nil { + return err + } + stat, err := file.Stat() + if err != nil { + file.Close() + return err + } + resp, code, err := s.agent.DoStream("PUT", dev.IP, dev.AgentPort, "/v1/models/"+item.Name, file, "application/octet-stream", stat.Size()) + file.Close() + if err != nil { + return err + } + if code >= 400 { + return fmt.Errorf("agent error: %d %s", code, strings.TrimSpace(string(resp))) + } + } + return nil } func (s *TaskService) syncResourceToDevice(task *models.Task, dev *models.Device, did string, syncAll bool) error { - if s.resources == nil { - return fmt.Errorf("resources repo is not configured") - } - items, err := s.resources.List() - if err != nil { - return err - } - if len(items) == 0 { - return fmt.Errorf("no standard resources configured") - } - targets := items[:0] - if syncAll { - targets = append(targets, items...) - } else { - payload, _ := task.Payload.(map[string]any) - targetName := strings.TrimSpace(stringAny(payload["resource_name"])) - if targetName == "" { - return fmt.Errorf("resource_name is required") - } - for _, item := range items { - if strings.TrimSpace(item.Name) == targetName { - targets = append(targets, item) - break - } - } - if len(targets) == 0 { - return fmt.Errorf("standard resource %q not found", targetName) - } - } - for _, item := range targets { - if strings.TrimSpace(item.FilePath) == "" { - continue // metadata-only resource, nothing to push - } - file, err := os.Open(item.FilePath) - if err != nil { - return err - } - stat, err := file.Stat() - if err != nil { - file.Close() - return err - } - agentPath := fmt.Sprintf("/v1/resources/%s/%s", item.ResourceType, item.Name) - resp, code, err := s.agent.DoStream("PUT", dev.IP, dev.AgentPort, agentPath, file, "application/octet-stream", stat.Size()) - file.Close() - if err != nil { - return err - } - if code >= 400 { - return fmt.Errorf("agent error: %d %s", code, strings.TrimSpace(string(resp))) - } - } - return nil + if s.resources == nil { + return fmt.Errorf("resources repo is not configured") + } + items, err := s.resources.List() + if err != nil { + return err + } + if len(items) == 0 { + return fmt.Errorf("no standard resources configured") + } + targets := items[:0] + if syncAll { + targets = append(targets, items...) + } else { + payload, _ := task.Payload.(map[string]any) + targetName := strings.TrimSpace(stringAny(payload["resource_name"])) + if targetName == "" { + return fmt.Errorf("resource_name is required") + } + for _, item := range items { + if strings.TrimSpace(item.Name) == targetName { + targets = append(targets, item) + break + } + } + if len(targets) == 0 { + return fmt.Errorf("standard resource %q not found", targetName) + } + } + for _, item := range targets { + if strings.TrimSpace(item.FilePath) == "" { + continue // metadata-only resource, nothing to push + } + file, err := os.Open(item.FilePath) + if err != nil { + return err + } + stat, err := file.Stat() + if err != nil { + file.Close() + return err + } + agentPath := fmt.Sprintf("/v1/resources/%s/%s", item.ResourceType, item.Name) + resp, code, err := s.agent.DoStream("PUT", dev.IP, dev.AgentPort, agentPath, file, "application/octet-stream", stat.Size()) + file.Close() + if err != nil { + return err + } + if code >= 400 { + return fmt.Errorf("agent error: %d %s", code, strings.TrimSpace(string(resp))) + } + } + return nil } func (s *TaskService) updateDeviceStatus(taskID, did string, status models.TaskStatus, progress float64, errStr string) { - s.mu.RLock() - task, ok := s.tasks[taskID] - s.mu.RUnlock() - if !ok { - return - } + s.mu.RLock() + task, ok := s.tasks[taskID] + s.mu.RUnlock() + if !ok { + return + } - task.Mu.Lock() - ds, ok := task.Devices[did] - if ok { - ds.Status = status - ds.Progress = progress - ds.Error = errStr - } - task.Mu.Unlock() - s.persistTask(task) + task.Mu.Lock() + ds, ok := task.Devices[did] + if ok { + ds.Status = status + ds.Progress = progress + ds.Error = errStr + } + task.Mu.Unlock() + s.persistTask(task) - // Notify listeners - s.lmu.RLock() - channels := s.listeners[taskID] - s.lmu.RUnlock() + // Notify listeners + s.lmu.RLock() + channels := s.listeners[taskID] + s.lmu.RUnlock() - update := &models.DeviceTaskStatus{ - DeviceID: did, - Status: status, - Progress: progress, - Error: errStr, - } + update := &models.DeviceTaskStatus{ + DeviceID: did, + Status: status, + Progress: progress, + Error: errStr, + } - for _, ch := range channels { - select { - case ch <- update: - default: - } - } + for _, ch := range channels { + select { + case ch <- update: + default: + } + } } func (s *TaskService) persistConfigState(task *models.Task, did string) { - if s == nil || s.stateRepo == nil || task == nil || task.Type != "config_apply" { - return - } - meta := taskPayloadMetadataForDevice(task.Payload, did) - overlaysJSON := "[]" - if len(meta.Overlays) > 0 { - if body, err := json.Marshal(meta.Overlays); err == nil { - overlaysJSON = string(body) - } - } - _ = s.stateRepo.UpsertState(did, meta.Template, meta.Profile, overlaysJSON, meta.ConfigID, meta.ConfigVersion, task.ID) + if s == nil || s.stateRepo == nil || task == nil || task.Type != "config_apply" { + return + } + meta := taskPayloadMetadataForDevice(task.Payload, did) + overlaysJSON := "[]" + if len(meta.Overlays) > 0 { + if body, err := json.Marshal(meta.Overlays); err == nil { + overlaysJSON = string(body) + } + } + _ = s.stateRepo.UpsertState(did, meta.Template, meta.Profile, overlaysJSON, meta.ConfigID, meta.ConfigVersion, task.ID) } func (s *TaskService) appendAuditLog(task *models.Task, did string, status models.TaskStatus, errText string) { - if s == nil || s.auditRepo == nil || task == nil { - return - } - meta := taskPayloadMetadataForDevice(task.Payload, did) - details := map[string]any{ - "task_id": task.ID, - "type": task.Type, - "status": status, - } - if meta.Template != "" { - details["template"] = meta.Template - } - if meta.Profile != "" { - details["profile"] = meta.Profile - } - if meta.ConfigID != "" { - details["config_id"] = meta.ConfigID - } - if meta.ConfigVersion != "" { - details["config_version"] = meta.ConfigVersion - } - if len(meta.Overlays) > 0 { - details["overlays"] = meta.Overlays - } - if errText != "" { - details["error"] = errText - } - body, _ := json.Marshal(details) - _ = s.auditRepo.AppendLog("system", task.Type, "device", did, string(body)) + if s == nil || s.auditRepo == nil || task == nil { + return + } + meta := taskPayloadMetadataForDevice(task.Payload, did) + details := map[string]any{ + "task_id": task.ID, + "type": task.Type, + "status": status, + } + if meta.Template != "" { + details["template"] = meta.Template + } + if meta.Profile != "" { + details["profile"] = meta.Profile + } + if meta.ConfigID != "" { + details["config_id"] = meta.ConfigID + } + if meta.ConfigVersion != "" { + details["config_version"] = meta.ConfigVersion + } + if len(meta.Overlays) > 0 { + details["overlays"] = meta.Overlays + } + if errText != "" { + details["error"] = errText + } + body, _ := json.Marshal(details) + _ = s.auditRepo.AppendLog("system", task.Type, "device", did, string(body)) } type taskMetadata struct { - Template string - Profile string - Overlays []string - ConfigID string - ConfigVersion string + Template string + Profile string + Overlays []string + ConfigID string + ConfigVersion string } func taskPayloadMetadataForDevice(payload any, deviceID string) taskMetadata { - var out taskMetadata - root, ok := payload.(map[string]any) - if !ok { - return out - } - var configRoot map[string]any - if rawConfigs, ok := root["configs"].(map[string]any); ok { - if rawConfig, ok := rawConfigs[deviceID].(map[string]any); ok { - configRoot = rawConfig - } - } - if configRoot == nil { - configRoot, ok = root["config"].(map[string]any) - } - if !ok { - return out - } - metadata, ok := configRoot["metadata"].(map[string]any) - if !ok { - return out - } - out.Template = stringAny(metadata["template"]) - out.Profile = stringAny(metadata["profile"]) - out.ConfigID = stringAny(metadata["config_id"]) - out.ConfigVersion = stringAny(metadata["config_version"]) - if rawOverlays, ok := metadata["overlays"].([]any); ok { - for _, item := range rawOverlays { - if v := stringAny(item); v != "" { - out.Overlays = append(out.Overlays, v) - } - } - } - return out + var out taskMetadata + root, ok := payload.(map[string]any) + if !ok { + return out + } + var configRoot map[string]any + if rawConfigs, ok := root["configs"].(map[string]any); ok { + if rawConfig, ok := rawConfigs[deviceID].(map[string]any); ok { + configRoot = rawConfig + } + } + if configRoot == nil { + configRoot, ok = root["config"].(map[string]any) + } + if !ok { + return out + } + metadata, ok := configRoot["metadata"].(map[string]any) + if !ok { + return out + } + out.Template = stringAny(metadata["template"]) + out.Profile = stringAny(metadata["profile"]) + out.ConfigID = stringAny(metadata["config_id"]) + out.ConfigVersion = stringAny(metadata["config_version"]) + if rawOverlays, ok := metadata["overlays"].([]any); ok { + for _, item := range rawOverlays { + if v := stringAny(item); v != "" { + out.Overlays = append(out.Overlays, v) + } + } + } + return out } func stringAny(v any) string { - if s, ok := v.(string); ok { - return s - } - return "" + if s, ok := v.(string); ok { + return s + } + return "" } func (s *TaskService) persistTask(task *models.Task) { - if s == nil || s.repo == nil || task == nil { - return - } - _ = s.repo.Save(task) + if s == nil || s.repo == nil || task == nil { + return + } + _ = s.repo.Save(task) } func (s *TaskService) Subscribe(taskID string) (chan *models.DeviceTaskStatus, func()) { - ch := make(chan *models.DeviceTaskStatus, 10) - s.lmu.Lock() - s.listeners[taskID] = append(s.listeners[taskID], ch) - s.lmu.Unlock() + ch := make(chan *models.DeviceTaskStatus, 10) + s.lmu.Lock() + s.listeners[taskID] = append(s.listeners[taskID], ch) + s.lmu.Unlock() - cleanup := func() { - s.lmu.Lock() - list := s.listeners[taskID] - for i, c := range list { - if c == ch { - s.listeners[taskID] = append(list[:i], list[i+1:]...) - break - } - } - s.lmu.Unlock() - } + cleanup := func() { + s.lmu.Lock() + list := s.listeners[taskID] + for i, c := range list { + if c == ch { + s.listeners[taskID] = append(list[:i], list[i+1:]...) + break + } + } + s.lmu.Unlock() + } - return ch, cleanup + return ch, cleanup } diff --git a/internal/service/task_test.go b/internal/service/task_test.go index 4463016..46b4363 100644 --- a/internal/service/task_test.go +++ b/internal/service/task_test.go @@ -1,315 +1,315 @@ package service import ( - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/storage" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "net" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/storage" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" ) func waitForTaskDone(t *testing.T, task *models.Task, timeout time.Duration) models.TaskStatus { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - task.Mu.RLock() - st := task.Status - task.Mu.RUnlock() - if st == models.TaskSuccess || st == models.TaskFailed { - return st - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("timed out waiting for task to finish") - return "" + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + task.Mu.RLock() + st := task.Status + task.Mu.RUnlock() + if st == models.TaskSuccess || st == models.TaskFailed { + return st + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for task to finish") + return "" } func TestTaskService_CreateTask(t *testing.T) { - cfg := &config.Config{ - Concurrency: 5, - } - // Mock registry - agent := NewAgentClient(cfg) - reg := NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{ - DeviceID: "dev1", - IP: "127.0.0.1", - AgentPort: 9100, - Online: true, - }) - svc := NewTaskService(cfg, agent, reg) + cfg := &config.Config{ + Concurrency: 5, + } + // Mock registry + agent := NewAgentClient(cfg) + reg := NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{ + DeviceID: "dev1", + IP: "127.0.0.1", + AgentPort: 9100, + Online: true, + }) + svc := NewTaskService(cfg, agent, reg) - task, err := svc.CreateTask("config_apply", []string{"dev1"}, map[string]string{"foo": "bar"}) - if err != nil { - t.Fatalf("failed to create task: %v", err) - } + task, err := svc.CreateTask("config_apply", []string{"dev1"}, map[string]string{"foo": "bar"}) + if err != nil { + t.Fatalf("failed to create task: %v", err) + } - if task.ID == "" { - t.Error("expected task ID to be set") - } + if task.ID == "" { + t.Error("expected task ID to be set") + } - // Wait for task to finish or fail (since agent is nil, it will fail) - time.Sleep(100 * time.Millisecond) + // Wait for task to finish or fail (since agent is nil, it will fail) + time.Sleep(100 * time.Millisecond) - task.Mu.RLock() - defer task.Mu.RUnlock() + task.Mu.RLock() + defer task.Mu.RUnlock() - if task.Devices["dev1"].Status == models.TaskPending { - t.Error("expected task status to change from pending") - } + if task.Devices["dev1"].Status == models.TaskPending { + t.Error("expected task status to change from pending") + } } func TestTaskService_Subscribe(t *testing.T) { - cfg := &config.Config{ - Concurrency: 5, - } - svc := NewTaskService(cfg, NewAgentClient(cfg), NewRegistryService(cfg, NewAgentClient(cfg))) + cfg := &config.Config{ + Concurrency: 5, + } + svc := NewTaskService(cfg, NewAgentClient(cfg), NewRegistryService(cfg, NewAgentClient(cfg))) - taskID := "test-task" - svc.tasks[taskID] = models.NewTask(taskID, "test", []string{"dev1"}, nil) + taskID := "test-task" + svc.tasks[taskID] = models.NewTask(taskID, "test", []string{"dev1"}, nil) - ch, cleanup := svc.Subscribe(taskID) - defer cleanup() + ch, cleanup := svc.Subscribe(taskID) + defer cleanup() - go func() { - svc.updateDeviceStatus(taskID, "dev1", models.TaskRunning, 0.5, "") - }() + go func() { + svc.updateDeviceStatus(taskID, "dev1", models.TaskRunning, 0.5, "") + }() - select { - case update := <-ch: - if update.DeviceID != "dev1" || update.Status != models.TaskRunning { - t.Errorf("unexpected update: %+v", update) - } - case <-time.After(1 * time.Second): - t.Error("timed out waiting for event") - } + select { + case update := <-ch: + if update.DeviceID != "dev1" || update.Status != models.TaskRunning { + t.Errorf("unexpected update: %+v", update) + } + case <-time.After(1 * time.Second): + t.Error("timed out waiting for event") + } } func TestTaskService_ConfigApply_UsesPayloadConfigField(t *testing.T) { - var gotBody any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - t.Fatalf("expected PUT, got %s", r.Method) - } - if r.URL.Path != "/v1/config" { - t.Fatalf("expected path /v1/config, got %s", r.URL.Path) - } - _ = json.NewDecoder(r.Body).Decode(&gotBody) - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() + var gotBody any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/v1/config" { + t.Fatalf("expected path /v1/config, got %s", r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() - u, _ := url.Parse(server.URL) - host, portStr, err := net.SplitHostPort(u.Host) - if err != nil { - t.Fatalf("SplitHostPort(%q): %v", u.Host, err) - } - port, _ := strconv.Atoi(portStr) + u, _ := url.Parse(server.URL) + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("SplitHostPort(%q): %v", u.Host, err) + } + port, _ := strconv.Atoi(portStr) - cfg := &config.Config{Concurrency: 1} - agent := NewAgentClient(cfg) - reg := NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) - svc := NewTaskService(cfg, agent, reg) + cfg := &config.Config{Concurrency: 1} + agent := NewAgentClient(cfg) + reg := NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) + svc := NewTaskService(cfg, agent, reg) - payload := map[string]any{"config": map[string]any{"a": 1}} - task, err := svc.CreateTask("config_apply", []string{"dev1"}, payload) - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - st := waitForTaskDone(t, task, 2*time.Second) - if st != models.TaskSuccess { - t.Fatalf("expected task success, got %s", st) - } + payload := map[string]any{"config": map[string]any{"a": 1}} + task, err := svc.CreateTask("config_apply", []string{"dev1"}, payload) + if err != nil { + t.Fatalf("failed to create task: %v", err) + } + st := waitForTaskDone(t, task, 2*time.Second) + if st != models.TaskSuccess { + t.Fatalf("expected task success, got %s", st) + } - m, ok := gotBody.(map[string]any) - if !ok || m["a"].(float64) != 1 { - t.Fatalf("expected body {a:1}, got %#v", gotBody) - } + m, ok := gotBody.(map[string]any) + if !ok || m["a"].(float64) != 1 { + t.Fatalf("expected body {a:1}, got %#v", gotBody) + } } func TestTaskService_MediaStart_IgnoresInvalidConfigShape(t *testing.T) { - var bodyBytes []byte - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Fatalf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/v1/media-server/start" { - t.Fatalf("expected path /v1/media-server/start, got %s", r.URL.Path) - } - bodyBytes, _ = io.ReadAll(r.Body) - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() + var bodyBytes []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/media-server/start" { + t.Fatalf("expected path /v1/media-server/start, got %s", r.URL.Path) + } + bodyBytes, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() - u, _ := url.Parse(server.URL) - host, portStr, err := net.SplitHostPort(u.Host) - if err != nil { - t.Fatalf("SplitHostPort(%q): %v", u.Host, err) - } - port, _ := strconv.Atoi(portStr) + u, _ := url.Parse(server.URL) + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("SplitHostPort(%q): %v", u.Host, err) + } + port, _ := strconv.Atoi(portStr) - cfg := &config.Config{Concurrency: 1} - agent := NewAgentClient(cfg) - reg := NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) - svc := NewTaskService(cfg, agent, reg) + cfg := &config.Config{Concurrency: 1} + agent := NewAgentClient(cfg) + reg := NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) + svc := NewTaskService(cfg, agent, reg) - // UI default payload_json is {"config":{}}; this should be ignored for media_start. - payload := map[string]any{"config": map[string]any{}} - task, err := svc.CreateTask("media_start", []string{"dev1"}, payload) - if err != nil { - t.Fatalf("failed to create task: %v", err) - } - st := waitForTaskDone(t, task, 2*time.Second) - if st != models.TaskSuccess { - t.Fatalf("expected task success, got %s", st) - } - if len(bodyBytes) != 0 { - t.Fatalf("expected empty body, got %q", string(bodyBytes)) - } + // UI default payload_json is {"config":{}}; this should be ignored for media_start. + payload := map[string]any{"config": map[string]any{}} + task, err := svc.CreateTask("media_start", []string{"dev1"}, payload) + if err != nil { + t.Fatalf("failed to create task: %v", err) + } + st := waitForTaskDone(t, task, 2*time.Second) + if st != models.TaskSuccess { + t.Fatalf("expected task success, got %s", st) + } + if len(bodyBytes) != 0 { + t.Fatalf("expected empty body, got %q", string(bodyBytes)) + } } func TestTaskService_ConfigApplyPersistsDeviceConfigStateAndAudit(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() - u, _ := url.Parse(server.URL) - host, portStr, err := net.SplitHostPort(u.Host) - if err != nil { - t.Fatalf("SplitHostPort(%q): %v", u.Host, err) - } - port, _ := strconv.Atoi(portStr) + u, _ := url.Parse(server.URL) + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("SplitHostPort(%q): %v", u.Host, err) + } + port, _ := strconv.Atoi(portStr) - cfg := &config.Config{Concurrency: 1} - agent := NewAgentClient(cfg) - reg := NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + cfg := &config.Config{Concurrency: 1} + agent := NewAgentClient(cfg) + reg := NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - svc := NewTaskService(cfg, agent, reg) - svc.SetDeviceConfigStateRepo(storage.NewDeviceConfigStateRepo(store.DB())) - svc.SetAuditLogRepo(storage.NewAuditLogsRepo(store.DB())) + svc := NewTaskService(cfg, agent, reg) + svc.SetDeviceConfigStateRepo(storage.NewDeviceConfigStateRepo(store.DB())) + svc.SetAuditLogRepo(storage.NewAuditLogsRepo(store.DB())) - payload := map[string]any{ - "config": map[string]any{ - "metadata": map[string]any{ - "template": "helmet", - "profile": "gate_a", - "overlays": []any{"night_relaxed"}, - "config_id": "cfg-001", - "config_version": "20260427.1", - }, - }, - } - task, err := svc.CreateTask("config_apply", []string{"dev1"}, payload) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - if st := waitForTaskDone(t, task, 2*time.Second); st != models.TaskSuccess { - t.Fatalf("expected task success, got %s", st) - } + payload := map[string]any{ + "config": map[string]any{ + "metadata": map[string]any{ + "template": "helmet", + "profile": "gate_a", + "overlays": []any{"night_relaxed"}, + "config_id": "cfg-001", + "config_version": "20260427.1", + }, + }, + } + task, err := svc.CreateTask("config_apply", []string{"dev1"}, payload) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + if st := waitForTaskDone(t, task, 2*time.Second); st != models.TaskSuccess { + t.Fatalf("expected task success, got %s", st) + } - state, err := storage.NewDeviceConfigStateRepo(store.DB()).Get("dev1") - if err != nil { - t.Fatalf("Get state: %v", err) - } - if state == nil || state.ProfileName != "gate_a" || state.ConfigID != "cfg-001" || state.LastAppliedTaskID != task.ID { - t.Fatalf("unexpected state: %#v", state) - } + state, err := storage.NewDeviceConfigStateRepo(store.DB()).Get("dev1") + if err != nil { + t.Fatalf("Get state: %v", err) + } + if state == nil || state.ProfileName != "gate_a" || state.ConfigID != "cfg-001" || state.LastAppliedTaskID != task.ID { + t.Fatalf("unexpected state: %#v", state) + } - logs, err := storage.NewAuditLogsRepo(store.DB()).List() - if err != nil { - t.Fatalf("List audit logs: %v", err) - } - if len(logs) == 0 || logs[0].Action != "config_apply" || logs[0].TargetID != "dev1" { - t.Fatalf("unexpected audit logs: %#v", logs) - } + logs, err := storage.NewAuditLogsRepo(store.DB()).List() + if err != nil { + t.Fatalf("List audit logs: %v", err) + } + if len(logs) == 0 || logs[0].Action != "config_apply" || logs[0].TargetID != "dev1" { + t.Fatalf("unexpected audit logs: %#v", logs) + } } func TestTaskService_ModelSyncAllUploadsStandardModels(t *testing.T) { - var uploads []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - t.Fatalf("expected PUT, got %s", r.Method) - } - uploads = append(uploads, r.URL.Path) - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() + var uploads []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("expected PUT, got %s", r.Method) + } + uploads = append(uploads, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() - u, _ := url.Parse(server.URL) - host, portStr, err := net.SplitHostPort(u.Host) - if err != nil { - t.Fatalf("SplitHostPort(%q): %v", u.Host, err) - } - port, _ := strconv.Atoi(portStr) + u, _ := url.Parse(server.URL) + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("SplitHostPort(%q): %v", u.Host, err) + } + port, _ := strconv.Atoi(portStr) - cfg := &config.Config{Concurrency: 1} - agent := NewAgentClient(cfg) - reg := NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) + cfg := &config.Config{Concurrency: 1} + agent := NewAgentClient(cfg) + reg := NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewModelsRepo(store.DB()) - modelDir := t.TempDir() - body := []byte("model-a") - sum := sha256SumHex(body) - fileName := "face_det_scrfd_500m_640_rk3588.rknn" - if err := os.WriteFile(filepath.Join(modelDir, fileName), body, 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - if err := repo.Save(storage.StandardModelRecord{ - Name: "face_det_scrfd_500m_640_rk3588", - FileName: fileName, - Version: "v1.0.0", - SHA256: sum, - }); err != nil { - t.Fatalf("Save model: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewModelsRepo(store.DB()) + modelDir := t.TempDir() + body := []byte("model-a") + sum := sha256SumHex(body) + fileName := "face_det_scrfd_500m_640_rk3588.rknn" + if err := os.WriteFile(filepath.Join(modelDir, fileName), body, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := repo.Save(storage.StandardModelRecord{ + Name: "face_det_scrfd_500m_640_rk3588", + FileName: fileName, + Version: "v1.0.0", + SHA256: sum, + }); err != nil { + t.Fatalf("Save model: %v", err) + } - svc := NewTaskService(cfg, agent, reg) - svc.SetStandardModels(repo, modelDir) + svc := NewTaskService(cfg, agent, reg) + svc.SetStandardModels(repo, modelDir) - task, err := svc.CreateTask("model_sync_all", []string{"dev1"}, map[string]any{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - if st := waitForTaskDone(t, task, 2*time.Second); st != models.TaskSuccess { - t.Fatalf("expected task success, got %s", st) - } - if len(uploads) != 1 || !strings.Contains(uploads[0], "/v1/models/face_det_scrfd_500m_640_rk3588") { - t.Fatalf("unexpected uploads: %#v", uploads) - } + task, err := svc.CreateTask("model_sync_all", []string{"dev1"}, map[string]any{}) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + if st := waitForTaskDone(t, task, 2*time.Second); st != models.TaskSuccess { + t.Fatalf("expected task success, got %s", st) + } + if len(uploads) != 1 || !strings.Contains(uploads[0], "/v1/models/face_det_scrfd_500m_640_rk3588") { + t.Fatalf("unexpected uploads: %#v", uploads) + } } func sha256SumHex(body []byte) string { - sum := sha256.Sum256(body) - return hex.EncodeToString(sum[:]) + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) } diff --git a/internal/service/template.go b/internal/service/template.go index ac90e71..fcb910e 100644 --- a/internal/service/template.go +++ b/internal/service/template.go @@ -1,61 +1,61 @@ package service import ( - "encoding/json" - "os" - "path/filepath" + "encoding/json" + "os" + "path/filepath" - "3588AdminBackend/internal/config" + "3588AdminBackend/internal/config" ) type Template struct { - Name string `json:"name"` - Schema interface{} `json:"schema"` - Body interface{} `json:"body"` + Name string `json:"name"` + Schema interface{} `json:"schema"` + Body interface{} `json:"body"` } type TemplateService struct { - cfg *config.Config + cfg *config.Config } func NewTemplateService(cfg *config.Config) *TemplateService { - return &TemplateService{cfg: cfg} + return &TemplateService{cfg: cfg} } func (s *TemplateService) ListTemplates() ([]Template, error) { - files, err := os.ReadDir("templates") - if err != nil { - return nil, err - } + files, err := os.ReadDir("templates") + if err != nil { + return nil, err + } - var list []Template - for _, f := range files { - if !f.IsDir() && filepath.Ext(f.Name()) == ".json" { - data, err := os.ReadFile(filepath.Join("templates", f.Name())) - if err != nil { - continue - } - var t Template - if err := json.Unmarshal(data, &t); err == nil { - if t.Name == "" { - t.Name = f.Name() // Fallback - } - list = append(list, t) - } - } - } - return list, nil + var list []Template + for _, f := range files { + if !f.IsDir() && filepath.Ext(f.Name()) == ".json" { + data, err := os.ReadFile(filepath.Join("templates", f.Name())) + if err != nil { + continue + } + var t Template + if err := json.Unmarshal(data, &t); err == nil { + if t.Name == "" { + t.Name = f.Name() // Fallback + } + list = append(list, t) + } + } + } + return list, nil } func (s *TemplateService) GetTemplate(name string) (*Template, error) { - path := filepath.Join("templates", name+".json") - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var t Template - if err := json.Unmarshal(data, &t); err != nil { - return nil, err - } - return &t, nil + path := filepath.Join("templates", name+".json") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var t Template + if err := json.Unmarshal(data, &t); err != nil { + return nil, err + } + return &t, nil } diff --git a/internal/service/template_slots.go b/internal/service/template_slots.go index 241dcef..402b6c0 100644 --- a/internal/service/template_slots.go +++ b/internal/service/template_slots.go @@ -3,65 +3,65 @@ package service import "fmt" type TemplateSlotGroup struct { - Inputs []TemplateSlot `json:"inputs"` - Services []TemplateSlot `json:"services"` - Outputs []TemplateSlot `json:"outputs"` + Inputs []TemplateSlot `json:"inputs"` + Services []TemplateSlot `json:"services"` + Outputs []TemplateSlot `json:"outputs"` } type TemplateSlot struct { - Name string `json:"name"` - Type string `json:"type"` - Required bool `json:"required"` - Description string `json:"description"` + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Description string `json:"description"` } func parseTemplateSlots(raw map[string]any) (TemplateSlotGroup, error) { - group := TemplateSlotGroup{} - slotsMap, _ := raw["slots"].(map[string]any) - if slotsMap == nil { - return group, nil - } - var err error - if group.Inputs, err = parseTemplateSlotList(slotsMap["inputs"]); err != nil { - return TemplateSlotGroup{}, fmt.Errorf("parse input slots: %w", err) - } - if group.Services, err = parseTemplateSlotList(slotsMap["services"]); err != nil { - return TemplateSlotGroup{}, fmt.Errorf("parse service slots: %w", err) - } - if group.Outputs, err = parseTemplateSlotList(slotsMap["outputs"]); err != nil { - return TemplateSlotGroup{}, fmt.Errorf("parse output slots: %w", err) - } - return group, nil + group := TemplateSlotGroup{} + slotsMap, _ := raw["slots"].(map[string]any) + if slotsMap == nil { + return group, nil + } + var err error + if group.Inputs, err = parseTemplateSlotList(slotsMap["inputs"]); err != nil { + return TemplateSlotGroup{}, fmt.Errorf("parse input slots: %w", err) + } + if group.Services, err = parseTemplateSlotList(slotsMap["services"]); err != nil { + return TemplateSlotGroup{}, fmt.Errorf("parse service slots: %w", err) + } + if group.Outputs, err = parseTemplateSlotList(slotsMap["outputs"]); err != nil { + return TemplateSlotGroup{}, fmt.Errorf("parse output slots: %w", err) + } + return group, nil } func parseTemplateSlotList(raw any) ([]TemplateSlot, error) { - items, _ := raw.([]any) - if len(items) == 0 { - return nil, nil - } - out := make([]TemplateSlot, 0, len(items)) - for _, item := range items { - slotMap, _ := item.(map[string]any) - if slotMap == nil { - return nil, fmt.Errorf("slot entry must be an object") - } - slot := TemplateSlot{ - Name: stringValue(slotMap["name"]), - Type: stringValue(slotMap["type"]), - Required: boolValue(slotMap["required"], false), - Description: stringValue(slotMap["description"]), - } - if slot.Name == "" { - return nil, fmt.Errorf("slot name is required") - } - if err := validateConfigName(slot.Name); err != nil { - return nil, fmt.Errorf("invalid slot name %q: %w", slot.Name, err) - } - if slot.Type == "" { - return nil, fmt.Errorf("slot type is required for %s", slot.Name) - } - out = append(out, slot) - } - return out, nil + items, _ := raw.([]any) + if len(items) == 0 { + return nil, nil + } + out := make([]TemplateSlot, 0, len(items)) + for _, item := range items { + slotMap, _ := item.(map[string]any) + if slotMap == nil { + return nil, fmt.Errorf("slot entry must be an object") + } + slot := TemplateSlot{ + Name: stringValue(slotMap["name"]), + Type: stringValue(slotMap["type"]), + Required: boolValue(slotMap["required"], false), + Description: stringValue(slotMap["description"]), + } + if slot.Name == "" { + return nil, fmt.Errorf("slot name is required") + } + if err := validateConfigName(slot.Name); err != nil { + return nil, fmt.Errorf("invalid slot name %q: %w", slot.Name, err) + } + if slot.Type == "" { + return nil, fmt.Errorf("slot type is required for %s", slot.Name) + } + out = append(out, slot) + } + return out, nil } diff --git a/internal/service/template_slots_test.go b/internal/service/template_slots_test.go index e2c4b85..840b46e 100644 --- a/internal/service/template_slots_test.go +++ b/internal/service/template_slots_test.go @@ -3,32 +3,32 @@ package service import "testing" func TestParseTemplateSlots(t *testing.T) { - raw := map[string]any{ - "slots": map[string]any{ - "inputs": []any{ - map[string]any{"name": "video_input_main", "type": "video_source", "required": true}, - }, - "services": []any{ - map[string]any{"name": "object_storage_main", "type": "object_storage", "required": true}, - }, - "outputs": []any{ - map[string]any{"name": "stream_output_main", "type": "stream_publish", "required": true}, - }, - }, - } + raw := map[string]any{ + "slots": map[string]any{ + "inputs": []any{ + map[string]any{"name": "video_input_main", "type": "video_source", "required": true}, + }, + "services": []any{ + map[string]any{"name": "object_storage_main", "type": "object_storage", "required": true}, + }, + "outputs": []any{ + map[string]any{"name": "stream_output_main", "type": "stream_publish", "required": true}, + }, + }, + } - slots, err := parseTemplateSlots(raw) - if err != nil { - t.Fatalf("parseTemplateSlots: %v", err) - } - if len(slots.Inputs) != 1 || slots.Inputs[0].Name != "video_input_main" { - t.Fatalf("unexpected input slots: %#v", slots.Inputs) - } - if len(slots.Services) != 1 || slots.Services[0].Type != "object_storage" { - t.Fatalf("unexpected service slots: %#v", slots.Services) - } - if len(slots.Outputs) != 1 || slots.Outputs[0].Name != "stream_output_main" { - t.Fatalf("unexpected output slots: %#v", slots.Outputs) - } + slots, err := parseTemplateSlots(raw) + if err != nil { + t.Fatalf("parseTemplateSlots: %v", err) + } + if len(slots.Inputs) != 1 || slots.Inputs[0].Name != "video_input_main" { + t.Fatalf("unexpected input slots: %#v", slots.Inputs) + } + if len(slots.Services) != 1 || slots.Services[0].Type != "object_storage" { + t.Fatalf("unexpected service slots: %#v", slots.Services) + } + if len(slots.Outputs) != 1 || slots.Outputs[0].Name != "stream_output_main" { + t.Fatalf("unexpected output slots: %#v", slots.Outputs) + } } diff --git a/internal/service/template_test.go b/internal/service/template_test.go index 1708193..fba2a0a 100644 --- a/internal/service/template_test.go +++ b/internal/service/template_test.go @@ -1,50 +1,50 @@ package service import ( - "3588AdminBackend/internal/config" - "os" - "testing" + "3588AdminBackend/internal/config" + "os" + "testing" ) func TestTemplateService(t *testing.T) { - // Ensure templates dir exists relative to test execution (which is internal/service) - _ = os.MkdirAll("templates", 0755) - - // Create a dummy template for test - content := `{ + // Ensure templates dir exists relative to test execution (which is internal/service) + _ = os.MkdirAll("templates", 0755) + + // Create a dummy template for test + content := `{ "name": "test-template", "schema": {}, "body": {} }` - _ = os.WriteFile("templates/test-template.json", []byte(content), 0644) - defer os.RemoveAll("templates") + _ = os.WriteFile("templates/test-template.json", []byte(content), 0644) + defer os.RemoveAll("templates") - cfg := &config.Config{} - svc := NewTemplateService(cfg) + cfg := &config.Config{} + svc := NewTemplateService(cfg) - list, err := svc.ListTemplates() - if err != nil { - t.Fatalf("failed to list templates: %v", err) - } + list, err := svc.ListTemplates() + if err != nil { + t.Fatalf("failed to list templates: %v", err) + } - found := false - for _, tpl := range list { - if tpl.Name == "test-template" { - found = true - break - } - } + found := false + for _, tpl := range list { + if tpl.Name == "test-template" { + found = true + break + } + } - if !found { - t.Error("expected test-template to be found") - } + if !found { + t.Error("expected test-template to be found") + } - tpl, err := svc.GetTemplate("test-template") - if err != nil { - t.Fatalf("failed to get template: %v", err) - } + tpl, err := svc.GetTemplate("test-template") + if err != nil { + t.Fatalf("failed to get template: %v", err) + } - if tpl.Name != "test-template" { - t.Errorf("expected test-template, got %s", tpl.Name) - } + if tpl.Name != "test-template" { + t.Errorf("expected test-template, got %s", tpl.Name) + } } diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index 80abf65..4b69a86 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -1,106 +1,106 @@ package storage import ( - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" ) type AssetRecord struct { - Name string - Description string - TemplateName string - BusinessName string - BodyJSON string - CreatedAt string - UpdatedAt string + Name string + Description string + TemplateName string + BusinessName string + BodyJSON string + CreatedAt string + UpdatedAt string } type IntegrationServiceRecord struct { - Name string - ServiceType string - Description string - Enabled bool - BodyJSON string - CreatedAt string - UpdatedAt string + Name string + ServiceType string + Description string + Enabled bool + BodyJSON string + CreatedAt string + UpdatedAt string } type VideoSourceRecord struct { - Name string - SourceType string - Area string - Description string - BodyJSON string - CreatedAt string - UpdatedAt string + Name string + SourceType string + Area string + Description string + BodyJSON string + CreatedAt string + UpdatedAt string } type DeviceAssignmentRecord struct { - DeviceID string - ProfileName string - Description string - BodyJSON string - CreatedAt string - UpdatedAt string + DeviceID string + ProfileName string + Description string + BodyJSON string + CreatedAt string + UpdatedAt string } type RecognitionUnitRecord struct { - SceneTemplateName string - Name string - DisplayName string - SiteName string - VideoSourceRef string - OutputChannel string - RTSPPort string - Description string - BodyJSON string - CreatedAt string - UpdatedAt string + SceneTemplateName string + Name string + DisplayName string + SiteName string + VideoSourceRef string + OutputChannel string + RTSPPort string + Description string + BodyJSON string + CreatedAt string + UpdatedAt string } type AssetsRepo struct { - db *sql.DB + db *sql.DB } func NewAssetsRepo(db *sql.DB) *AssetsRepo { - return &AssetsRepo{db: db} + return &AssetsRepo{db: db} } func (r *AssetsRepo) SaveTemplate(name string, description string, bodyJSON string) error { - return r.saveAsset("templates", AssetRecord{ - Name: name, - Description: description, - BodyJSON: bodyJSON, - }) + return r.saveAsset("templates", AssetRecord{ + Name: name, + Description: description, + BodyJSON: bodyJSON, + }) } func (r *AssetsRepo) SaveProfile(name string, templateName string, businessName string, description string, bodyJSON string) error { - return r.saveAsset("profiles", AssetRecord{ - Name: name, - TemplateName: templateName, - BusinessName: businessName, - Description: description, - BodyJSON: bodyJSON, - }) + return r.saveAsset("profiles", AssetRecord{ + Name: name, + TemplateName: templateName, + BusinessName: businessName, + Description: description, + BodyJSON: bodyJSON, + }) } func (r *AssetsRepo) SaveOverlay(name string, description string, bodyJSON string) error { - return r.saveAsset("overlays", AssetRecord{ - Name: name, - Description: description, - BodyJSON: bodyJSON, - }) + return r.saveAsset("overlays", AssetRecord{ + Name: name, + Description: description, + BodyJSON: bodyJSON, + }) } func (r *AssetsRepo) SaveIntegrationService(name string, serviceType string, description string, enabled bool, bodyJSON string) error { - if r == nil || r.db == nil { - return nil - } - now := time.Now().Format(time.RFC3339) - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` INSERT INTO integration_services(name, type, description, enabled, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM integration_services WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET @@ -110,15 +110,15 @@ ON CONFLICT(name) DO UPDATE SET body_json=excluded.body_json, updated_at=excluded.updated_at `, name, serviceType, description, enabled, bodyJSON, name, now, now) - return err + return err } func (r *AssetsRepo) SaveVideoSource(name string, sourceType string, area string, description string, bodyJSON string) error { - if r == nil || r.db == nil { - return nil - } - now := time.Now().Format(time.RFC3339) - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` INSERT INTO video_sources(name, source_type, area, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM video_sources WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET @@ -128,15 +128,15 @@ ON CONFLICT(name) DO UPDATE SET body_json=excluded.body_json, updated_at=excluded.updated_at `, name, sourceType, area, description, bodyJSON, name, now, now) - return err + return err } func (r *AssetsRepo) SaveDeviceAssignment(deviceID string, profileName string, description string, bodyJSON string) error { - if r == nil || r.db == nil { - return nil - } - now := time.Now().Format(time.RFC3339) - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` INSERT INTO device_assignments(device_id, profile_name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM device_assignments WHERE device_id = ?), ?), ?) ON CONFLICT(device_id) DO UPDATE SET @@ -145,15 +145,15 @@ ON CONFLICT(device_id) DO UPDATE SET body_json=excluded.body_json, updated_at=excluded.updated_at `, deviceID, profileName, description, bodyJSON, deviceID, now, now) - return err + return err } func (r *AssetsRepo) SaveRecognitionUnit(record RecognitionUnitRecord) error { - if r == nil || r.db == nil { - return nil - } - now := time.Now().Format(time.RFC3339) - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?) ON CONFLICT(scene_template_name, name) DO UPDATE SET @@ -166,369 +166,369 @@ ON CONFLICT(scene_template_name, name) DO UPDATE SET body_json=excluded.body_json, updated_at=excluded.updated_at `, record.SceneTemplateName, record.Name, record.DisplayName, record.SiteName, record.VideoSourceRef, record.OutputChannel, record.RTSPPort, record.Description, record.BodyJSON, record.SceneTemplateName, record.Name, now, now) - return err + return err } func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) { - return r.listAssets("templates") + return r.listAssets("templates") } func (r *AssetsRepo) ListProfiles() ([]AssetRecord, error) { - return r.listAssets("profiles") + return r.listAssets("profiles") } func (r *AssetsRepo) ListOverlays() ([]AssetRecord, error) { - return r.listAssets("overlays") + return r.listAssets("overlays") } func (r *AssetsRepo) ListIntegrationServices() ([]IntegrationServiceRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT name, type, description, enabled, body_json, created_at, updated_at FROM integration_services ORDER BY updated_at DESC, name ASC `) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - var out []IntegrationServiceRecord - for rows.Next() { - var item IntegrationServiceRecord - if err := rows.Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + var out []IntegrationServiceRecord + for rows.Next() { + var item IntegrationServiceRecord + if err := rows.Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } func (r *AssetsRepo) ListVideoSources() ([]VideoSourceRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT name, source_type, area, description, body_json, created_at, updated_at FROM video_sources ORDER BY updated_at DESC, name ASC `) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - var out []VideoSourceRecord - for rows.Next() { - var item VideoSourceRecord - if err := rows.Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + var out []VideoSourceRecord + for rows.Next() { + var item VideoSourceRecord + if err := rows.Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } func (r *AssetsRepo) ListDeviceAssignments() ([]DeviceAssignmentRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT device_id, profile_name, description, body_json, created_at, updated_at FROM device_assignments ORDER BY updated_at DESC, device_id ASC `) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - var out []DeviceAssignmentRecord - for rows.Next() { - var item DeviceAssignmentRecord - if err := rows.Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + var out []DeviceAssignmentRecord + for rows.Next() { + var item DeviceAssignmentRecord + if err := rows.Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } func (r *AssetsRepo) ListRecognitionUnits() ([]RecognitionUnitRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at FROM recognition_units ORDER BY scene_template_name ASC, name ASC `) - if err != nil { - return nil, err - } - defer rows.Close() - var out []RecognitionUnitRecord - for rows.Next() { - var item RecognitionUnitRecord - if err := rows.Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + if err != nil { + return nil, err + } + defer rows.Close() + var out []RecognitionUnitRecord + for rows.Next() { + var item RecognitionUnitRecord + if err := rows.Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) { - return r.getAsset("templates", name) + return r.getAsset("templates", name) } func (r *AssetsRepo) GetProfile(name string) (*AssetRecord, error) { - return r.getAsset("profiles", name) + return r.getAsset("profiles", name) } func (r *AssetsRepo) GetOverlay(name string) (*AssetRecord, error) { - return r.getAsset("overlays", name) + return r.getAsset("overlays", name) } func (r *AssetsRepo) GetIntegrationService(name string) (*IntegrationServiceRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - var item IntegrationServiceRecord - err := r.db.QueryRow(` + if r == nil || r.db == nil { + return nil, nil + } + var item IntegrationServiceRecord + err := r.db.QueryRow(` SELECT name, type, description, enabled, body_json, created_at, updated_at FROM integration_services WHERE name = ? `, name).Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &item, nil + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil } func (r *AssetsRepo) GetVideoSource(name string) (*VideoSourceRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - var item VideoSourceRecord - err := r.db.QueryRow(` + if r == nil || r.db == nil { + return nil, nil + } + var item VideoSourceRecord + err := r.db.QueryRow(` SELECT name, source_type, area, description, body_json, created_at, updated_at FROM video_sources WHERE name = ? `, name).Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &item, nil + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil } func (r *AssetsRepo) GetDeviceAssignment(deviceID string) (*DeviceAssignmentRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - var item DeviceAssignmentRecord - err := r.db.QueryRow(` + if r == nil || r.db == nil { + return nil, nil + } + var item DeviceAssignmentRecord + err := r.db.QueryRow(` SELECT device_id, profile_name, description, body_json, created_at, updated_at FROM device_assignments WHERE device_id = ? `, deviceID).Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &item, nil + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil } func (r *AssetsRepo) GetRecognitionUnit(sceneTemplateName string, name string) (*RecognitionUnitRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - var item RecognitionUnitRecord - err := r.db.QueryRow(` + if r == nil || r.db == nil { + return nil, nil + } + var item RecognitionUnitRecord + err := r.db.QueryRow(` SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at FROM recognition_units WHERE scene_template_name = ? AND name = ? `, sceneTemplateName, name).Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &item, nil + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil } func (r *AssetsRepo) DeleteTemplate(name string) error { - return r.deleteAsset("templates", name) + return r.deleteAsset("templates", name) } func (r *AssetsRepo) DeleteProfile(name string) error { - return r.deleteAsset("profiles", name) + return r.deleteAsset("profiles", name) } func (r *AssetsRepo) DeleteIntegrationService(name string) error { - if r == nil || r.db == nil { - return nil - } - _, err := r.db.Exec(`DELETE FROM integration_services WHERE name = ?`, name) - return err + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`DELETE FROM integration_services WHERE name = ?`, name) + return err } func (r *AssetsRepo) DeleteVideoSource(name string) error { - if r == nil || r.db == nil { - return nil - } - _, err := r.db.Exec(`DELETE FROM video_sources WHERE name = ?`, name) - return err + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`DELETE FROM video_sources WHERE name = ?`, name) + return err } func (r *AssetsRepo) DeleteDeviceAssignment(deviceID string) error { - if r == nil || r.db == nil { - return nil - } - _, err := r.db.Exec(`DELETE FROM device_assignments WHERE device_id = ?`, deviceID) - return err + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`DELETE FROM device_assignments WHERE device_id = ?`, deviceID) + return err } func (r *AssetsRepo) DeleteRecognitionUnit(sceneTemplateName string, name string) error { - if r == nil || r.db == nil { - return nil - } - _, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ? AND name = ?`, sceneTemplateName, name) - return err + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ? AND name = ?`, sceneTemplateName, name) + return err } func (r *AssetsRepo) DeleteOverlay(name string) error { - return r.deleteAsset("overlays", name) + return r.deleteAsset("overlays", name) } func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error { - if r == nil || r.db == nil { - return nil - } - oldName = strings.TrimSpace(oldName) - newName = strings.TrimSpace(newName) - if oldName == "" || newName == "" { - return fmt.Errorf("template name is required") - } - if oldName == newName { - return r.SaveTemplate(newName, description, bodyJSON) - } + if r == nil || r.db == nil { + return nil + } + oldName = strings.TrimSpace(oldName) + newName = strings.TrimSpace(newName) + if oldName == "" || newName == "" { + return fmt.Errorf("template name is required") + } + if oldName == newName { + return r.SaveTemplate(newName, description, bodyJSON) + } - tx, err := r.db.Begin() - if err != nil { - return err - } - defer func() { - if tx != nil { - _ = tx.Rollback() - } - }() + tx, err := r.db.Begin() + if err != nil { + return err + } + defer func() { + if tx != nil { + _ = tx.Rollback() + } + }() - var exists int - if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, oldName).Scan(&exists); err != nil { - return err - } - if exists == 0 { - return sql.ErrNoRows - } - if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, newName).Scan(&exists); err != nil { - return err - } - if exists > 0 { - return fmt.Errorf("template %q already exists", newName) - } + var exists int + if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, oldName).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return sql.ErrNoRows + } + if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, newName).Scan(&exists); err != nil { + return err + } + if exists > 0 { + return fmt.Errorf("template %q already exists", newName) + } - now := time.Now().Format(time.RFC3339) - if _, err := tx.Exec(` + now := time.Now().Format(time.RFC3339) + if _, err := tx.Exec(` INSERT INTO templates(name, description, body_json, created_at, updated_at) SELECT ?, ?, ?, created_at, ? FROM templates WHERE name = ? `, newName, description, bodyJSON, now, oldName); err != nil { - return err - } + return err + } - rows, err := tx.Query(` + rows, err := tx.Query(` SELECT name, description, business_name, body_json FROM scene_templates WHERE primary_template_name = ? OR body_json LIKE ? `, oldName, "%\"primary_template_name\": \""+oldName+"\"%") - if err != nil { - return err - } - defer rows.Close() + if err != nil { + return err + } + defer rows.Close() - type profileUpdate struct { - name string - description string - businessName string - bodyJSON string - } - updates := make([]profileUpdate, 0) - for rows.Next() { - var item profileUpdate - if err := rows.Scan(&item.name, &item.description, &item.businessName, &item.bodyJSON); err != nil { - return err - } - rewritten, changed, err := rewriteProfileTemplateRefs(item.bodyJSON, oldName, newName) - if err != nil { - return err - } - if changed { - item.bodyJSON = rewritten - } - updates = append(updates, item) - } - if err := rows.Err(); err != nil { - return err - } - for _, item := range updates { - if _, err := tx.Exec(` + type profileUpdate struct { + name string + description string + businessName string + bodyJSON string + } + updates := make([]profileUpdate, 0) + for rows.Next() { + var item profileUpdate + if err := rows.Scan(&item.name, &item.description, &item.businessName, &item.bodyJSON); err != nil { + return err + } + rewritten, changed, err := rewriteProfileTemplateRefs(item.bodyJSON, oldName, newName) + if err != nil { + return err + } + if changed { + item.bodyJSON = rewritten + } + updates = append(updates, item) + } + if err := rows.Err(); err != nil { + return err + } + for _, item := range updates { + if _, err := tx.Exec(` UPDATE scene_templates SET primary_template_name = ?, body_json = ?, updated_at = ? WHERE name = ? `, newName, item.bodyJSON, now, item.name); err != nil { - return err - } - } + return err + } + } - if _, err := tx.Exec(` + if _, err := tx.Exec(` UPDATE recognition_units SET body_json = REPLACE(body_json, ?, ?), updated_at = ? WHERE body_json LIKE ? `, `"`+"template"+`": "`+oldName+`"`, `"`+"template"+`": "`+newName+`"`, now, "%\"template\": \""+oldName+"\"%"); err != nil { - return err - } + return err + } - if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil { - return err - } - if err := tx.Commit(); err != nil { - return err - } - tx = nil - return nil + if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + tx = nil + return nil } func (r *AssetsRepo) saveAsset(table string, record AssetRecord) error { - if r == nil || r.db == nil { - return nil - } - now := time.Now().Format(time.RFC3339) - switch table { - case "templates", "overlays": - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + switch table { + case "templates", "overlays": + _, err := r.db.Exec(` INSERT INTO `+table+`(name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, COALESCE((SELECT created_at FROM `+table+` WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET @@ -536,18 +536,18 @@ ON CONFLICT(name) DO UPDATE SET body_json=excluded.body_json, updated_at=excluded.updated_at `, record.Name, record.Description, record.BodyJSON, record.Name, now, now) - return err - case "profiles": - normalized, units, replaceUnits, err := normalizeProfileRecord(record) - if err != nil { - return err - } - tx, err := r.db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - _, err = tx.Exec(` + return err + case "profiles": + normalized, units, replaceUnits, err := normalizeProfileRecord(record) + if err != nil { + return err + } + tx, err := r.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + _, err = tx.Exec(` INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM scene_templates WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET @@ -557,15 +557,15 @@ ON CONFLICT(name) DO UPDATE SET body_json=excluded.body_json, updated_at=excluded.updated_at `, normalized.Name, normalized.TemplateName, normalized.BusinessName, normalized.Description, normalized.BodyJSON, normalized.Name, now, now) - if err != nil { - return err - } - if replaceUnits { - if _, err := tx.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil { - return err - } - for _, unit := range units { - if _, err := tx.Exec(` + if err != nil { + return err + } + if replaceUnits { + if _, err := tx.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil { + return err + } + for _, unit := range units { + if _, err := tx.Exec(` INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?) ON CONFLICT(scene_template_name, name) DO UPDATE SET @@ -578,182 +578,182 @@ ON CONFLICT(scene_template_name, name) DO UPDATE SET body_json=excluded.body_json, updated_at=excluded.updated_at `, unit.SceneTemplateName, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, unit.Description, unit.BodyJSON, unit.SceneTemplateName, unit.Name, now, now); err != nil { - return err - } - } - } - return tx.Commit() - default: - return nil - } + return err + } + } + } + return tx.Commit() + default: + return nil + } } func normalizeProfileRecord(record AssetRecord) (AssetRecord, []RecognitionUnitRecord, bool, error) { - raw := map[string]any{} - if strings.TrimSpace(record.BodyJSON) != "" { - if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { - return AssetRecord{}, nil, false, err - } - } - if raw == nil { - raw = map[string]any{} - } - _, hasInstances := raw["instances"] - if !hasInstances { - return record, nil, false, nil - } - templateDoc, units, err := splitLegacyProfileDocument(struct { - Name string - TemplateName string - BusinessName string - Description string - BodyJSON string - CreatedAt string - UpdatedAt string - }{ - Name: record.Name, - TemplateName: record.TemplateName, - BusinessName: record.BusinessName, - Description: record.Description, - BodyJSON: record.BodyJSON, - CreatedAt: record.CreatedAt, - UpdatedAt: record.UpdatedAt, - }) - if err != nil { - return AssetRecord{}, nil, false, err - } - body, err := json.MarshalIndent(templateDoc, "", " ") - if err != nil { - return AssetRecord{}, nil, false, err - } - normalized := record - normalized.BodyJSON = string(append(body, '\n')) - outUnits := make([]RecognitionUnitRecord, 0, len(units)) - for _, unit := range units { - outUnits = append(outUnits, RecognitionUnitRecord{ - SceneTemplateName: record.Name, - Name: unit.Name, - DisplayName: unit.DisplayName, - SiteName: unit.SiteName, - VideoSourceRef: unit.VideoSourceRef, - OutputChannel: unit.OutputChannel, - RTSPPort: unit.RTSPPort, - Description: record.Description, - BodyJSON: unit.BodyJSON, - CreatedAt: record.CreatedAt, - UpdatedAt: record.UpdatedAt, - }) - } - return normalized, outUnits, true, nil + raw := map[string]any{} + if strings.TrimSpace(record.BodyJSON) != "" { + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return AssetRecord{}, nil, false, err + } + } + if raw == nil { + raw = map[string]any{} + } + _, hasInstances := raw["instances"] + if !hasInstances { + return record, nil, false, nil + } + templateDoc, units, err := splitLegacyProfileDocument(struct { + Name string + TemplateName string + BusinessName string + Description string + BodyJSON string + CreatedAt string + UpdatedAt string + }{ + Name: record.Name, + TemplateName: record.TemplateName, + BusinessName: record.BusinessName, + Description: record.Description, + BodyJSON: record.BodyJSON, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + }) + if err != nil { + return AssetRecord{}, nil, false, err + } + body, err := json.MarshalIndent(templateDoc, "", " ") + if err != nil { + return AssetRecord{}, nil, false, err + } + normalized := record + normalized.BodyJSON = string(append(body, '\n')) + outUnits := make([]RecognitionUnitRecord, 0, len(units)) + for _, unit := range units { + outUnits = append(outUnits, RecognitionUnitRecord{ + SceneTemplateName: record.Name, + Name: unit.Name, + DisplayName: unit.DisplayName, + SiteName: unit.SiteName, + VideoSourceRef: unit.VideoSourceRef, + OutputChannel: unit.OutputChannel, + RTSPPort: unit.RTSPPort, + Description: record.Description, + BodyJSON: unit.BodyJSON, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + }) + } + return normalized, outUnits, true, nil } func (r *AssetsRepo) listAssets(table string) ([]AssetRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - query := ` + if r == nil || r.db == nil { + return nil, nil + } + query := ` SELECT name, description, body_json, created_at, updated_at, '', '' FROM ` + table + ` ORDER BY updated_at DESC, name ASC ` - if table == "profiles" { - query = ` + if table == "profiles" { + query = ` SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name FROM scene_templates ORDER BY updated_at DESC, name ASC ` - } - rows, err := r.db.Query(query) - if err != nil { - return nil, err - } - defer rows.Close() + } + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() - var out []AssetRecord - for rows.Next() { - var item AssetRecord - if err := rows.Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + var out []AssetRecord + for rows.Next() { + var item AssetRecord + if err := rows.Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } func (r *AssetsRepo) getAsset(table string, name string) (*AssetRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - query := ` + if r == nil || r.db == nil { + return nil, nil + } + query := ` SELECT name, description, body_json, created_at, updated_at, '', '' FROM ` + table + ` WHERE name = ? ` - if table == "profiles" { - query = ` + if table == "profiles" { + query = ` SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name FROM scene_templates WHERE name = ? ` - } + } - var item AssetRecord - err := r.db.QueryRow(query, name).Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &item, nil + var item AssetRecord + err := r.db.QueryRow(query, name).Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil } func (r *AssetsRepo) deleteAsset(table string, name string) error { - if r == nil || r.db == nil { - return nil - } - if table == "profiles" { - table = "scene_templates" - } - _, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name) - return err + if r == nil || r.db == nil { + return nil + } + if table == "profiles" { + table = "scene_templates" + } + _, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name) + return err } func rewriteProfileTemplateRefs(bodyJSON string, oldName string, newName string) (string, bool, error) { - var raw map[string]any - if err := json.Unmarshal([]byte(bodyJSON), &raw); err != nil { - return "", false, err - } - changed := false - if strings.TrimSpace(valueString(raw["primary_template_name"])) == oldName { - raw["primary_template_name"] = newName - changed = true - } - instances, _ := raw["instances"].([]any) - for _, item := range instances { - instanceMap, _ := item.(map[string]any) - if strings.TrimSpace(valueString(instanceMap["template"])) == oldName { - instanceMap["template"] = newName - changed = true - } - } - if !changed { - return bodyJSON, false, nil - } - body, err := json.MarshalIndent(raw, "", " ") - if err != nil { - return "", false, err - } - return string(append(body, '\n')), true, nil + var raw map[string]any + if err := json.Unmarshal([]byte(bodyJSON), &raw); err != nil { + return "", false, err + } + changed := false + if strings.TrimSpace(valueString(raw["primary_template_name"])) == oldName { + raw["primary_template_name"] = newName + changed = true + } + instances, _ := raw["instances"].([]any) + for _, item := range instances { + instanceMap, _ := item.(map[string]any) + if strings.TrimSpace(valueString(instanceMap["template"])) == oldName { + instanceMap["template"] = newName + changed = true + } + } + if !changed { + return bodyJSON, false, nil + } + body, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return "", false, err + } + return string(append(body, '\n')), true, nil } func valueString(v any) string { - switch vv := v.(type) { - case string: - return vv - case float64: - return fmt.Sprintf("%v", vv) - default: - return "" - } + switch vv := v.(type) { + case string: + return vv + case float64: + return fmt.Sprintf("%v", vv) + default: + return "" + } } diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go index 9db8377..3c4649c 100644 --- a/internal/storage/assets_repo_test.go +++ b/internal/storage/assets_repo_test.go @@ -1,186 +1,186 @@ package storage import ( - "strings" - "testing" + "strings" + "testing" ) func TestAssetsRepoStoresTemplateProfileAndOverlayJSON(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A"}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveOverlay("night_relaxed", "overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil { - t.Fatalf("SaveOverlay: %v", err) - } + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A"}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveOverlay("night_relaxed", "overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil { + t.Fatalf("SaveOverlay: %v", err) + } - templates, err := repo.ListTemplates() - if err != nil { - t.Fatalf("ListTemplates: %v", err) - } - profiles, err := repo.ListProfiles() - if err != nil { - t.Fatalf("ListProfiles: %v", err) - } - overlays, err := repo.ListOverlays() - if err != nil { - t.Fatalf("ListOverlays: %v", err) - } - if len(templates) != 1 || templates[0].Name != "helmet" { - t.Fatalf("unexpected templates: %#v", templates) - } - if len(profiles) != 1 || profiles[0].Name != "gate_a" || profiles[0].TemplateName != "helmet" { - t.Fatalf("unexpected profiles: %#v", profiles) - } - if len(overlays) != 1 || overlays[0].Name != "night_relaxed" { - t.Fatalf("unexpected overlays: %#v", overlays) - } + templates, err := repo.ListTemplates() + if err != nil { + t.Fatalf("ListTemplates: %v", err) + } + profiles, err := repo.ListProfiles() + if err != nil { + t.Fatalf("ListProfiles: %v", err) + } + overlays, err := repo.ListOverlays() + if err != nil { + t.Fatalf("ListOverlays: %v", err) + } + if len(templates) != 1 || templates[0].Name != "helmet" { + t.Fatalf("unexpected templates: %#v", templates) + } + if len(profiles) != 1 || profiles[0].Name != "gate_a" || profiles[0].TemplateName != "helmet" { + t.Fatalf("unexpected profiles: %#v", profiles) + } + if len(overlays) != 1 || overlays[0].Name != "night_relaxed" { + t.Fatalf("unexpected overlays: %#v", overlays) + } } func TestAssetsRepoRenameTemplateUpdatesProfileReferences(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } - err := repo.RenameTemplate("helmet", "helmet_v2", "helmet v2", `{"name":"helmet_v2","description":"helmet v2","template":{"nodes":[],"edges":[]}}`) - if err != nil { - t.Fatalf("RenameTemplate: %v", err) - } + err := repo.RenameTemplate("helmet", "helmet_v2", "helmet v2", `{"name":"helmet_v2","description":"helmet v2","template":{"nodes":[],"edges":[]}}`) + if err != nil { + t.Fatalf("RenameTemplate: %v", err) + } - oldRecord, err := repo.GetTemplate("helmet") - if err != nil { - t.Fatalf("GetTemplate old: %v", err) - } - if oldRecord != nil { - t.Fatalf("expected old template to be removed, got %#v", oldRecord) - } - newRecord, err := repo.GetTemplate("helmet_v2") - if err != nil { - t.Fatalf("GetTemplate new: %v", err) - } - if newRecord == nil || newRecord.Description != "helmet v2" || !strings.Contains(newRecord.BodyJSON, `"name":"helmet_v2"`) { - t.Fatalf("expected renamed template, got %#v", newRecord) - } - profile, err := repo.GetProfile("gate_a") - if err != nil { - t.Fatalf("GetProfile: %v", err) - } - if profile == nil || profile.TemplateName != "helmet_v2" || !strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`) { - t.Fatalf("expected profile template ref updated, got %#v", profile) - } + oldRecord, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate old: %v", err) + } + if oldRecord != nil { + t.Fatalf("expected old template to be removed, got %#v", oldRecord) + } + newRecord, err := repo.GetTemplate("helmet_v2") + if err != nil { + t.Fatalf("GetTemplate new: %v", err) + } + if newRecord == nil || newRecord.Description != "helmet v2" || !strings.Contains(newRecord.BodyJSON, `"name":"helmet_v2"`) { + t.Fatalf("expected renamed template, got %#v", newRecord) + } + profile, err := repo.GetProfile("gate_a") + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile == nil || profile.TemplateName != "helmet_v2" || !strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`) { + t.Fatalf("expected profile template ref updated, got %#v", profile) + } } func TestAssetsRepoDeleteTemplateRemovesRecord(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.DeleteTemplate("helmet"); err != nil { - t.Fatalf("DeleteTemplate: %v", err) - } - record, err := repo.GetTemplate("helmet") - if err != nil { - t.Fatalf("GetTemplate: %v", err) - } - if record != nil { - t.Fatalf("expected template deleted, got %#v", record) - } + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.DeleteTemplate("helmet"); err != nil { + t.Fatalf("DeleteTemplate: %v", err) + } + record, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if record != nil { + t.Fatalf("expected template deleted, got %#v", record) + } } func TestAssetsRepoStoresIntegrationServiceDisabledState(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewAssetsRepo(store.DB()) - if err := repo.SaveIntegrationService( - "minio_primary", - "object_storage", - "primary object store", - false, - `{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false}`, - ); err != nil { - t.Fatalf("SaveIntegrationService: %v", err) - } + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + false, + `{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } - records, err := repo.ListIntegrationServices() - if err != nil { - t.Fatalf("ListIntegrationServices: %v", err) - } - if len(records) != 1 { - t.Fatalf("expected one integration service, got %#v", records) - } - if records[0].ServiceType != "object_storage" || records[0].Enabled { - t.Fatalf("unexpected integration service record: %#v", records[0]) - } + records, err := repo.ListIntegrationServices() + if err != nil { + t.Fatalf("ListIntegrationServices: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one integration service, got %#v", records) + } + if records[0].ServiceType != "object_storage" || records[0].Enabled { + t.Fatalf("unexpected integration service record: %#v", records[0]) + } - record, err := repo.GetIntegrationService("minio_primary") - if err != nil { - t.Fatalf("GetIntegrationService: %v", err) - } - if record == nil { - t.Fatal("expected integration service record") - } - if record.ServiceType != "object_storage" || record.Enabled { - t.Fatalf("unexpected integration service fetch: %#v", record) - } - if !strings.Contains(record.BodyJSON, `"provider":"minio"`) { - t.Fatalf("expected body_json payload preserved, got %#v", record) - } + record, err := repo.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if record == nil { + t.Fatal("expected integration service record") + } + if record.ServiceType != "object_storage" || record.Enabled { + t.Fatalf("unexpected integration service fetch: %#v", record) + } + if !strings.Contains(record.BodyJSON, `"provider":"minio"`) { + t.Fatalf("expected body_json payload preserved, got %#v", record) + } } func TestAssetsRepoStoresVideoSource(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewAssetsRepo(store.DB()) - if err := repo.SaveVideoSource( - "gate_cam_01", - "rtsp", - "东门入口", - "东门主入口摄像头", - `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264"}}`, - ); err != nil { - t.Fatalf("SaveVideoSource: %v", err) - } + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveVideoSource( + "gate_cam_01", + "rtsp", + "东门入口", + "东门主入口摄像头", + `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264"}}`, + ); err != nil { + t.Fatalf("SaveVideoSource: %v", err) + } - records, err := repo.ListVideoSources() - if err != nil { - t.Fatalf("ListVideoSources: %v", err) - } - if len(records) != 1 { - t.Fatalf("expected one video source, got %#v", records) - } - if records[0].SourceType != "rtsp" || records[0].Area != "东门入口" { - t.Fatalf("unexpected video source record: %#v", records[0]) - } + records, err := repo.ListVideoSources() + if err != nil { + t.Fatalf("ListVideoSources: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one video source, got %#v", records) + } + if records[0].SourceType != "rtsp" || records[0].Area != "东门入口" { + t.Fatalf("unexpected video source record: %#v", records[0]) + } - record, err := repo.GetVideoSource("gate_cam_01") - if err != nil { - t.Fatalf("GetVideoSource: %v", err) - } - if record == nil { - t.Fatal("expected video source record") - } - if record.SourceType != "rtsp" || record.Area != "东门入口" { - t.Fatalf("unexpected video source fetch: %#v", record) - } - if !strings.Contains(record.BodyJSON, `"resolution":"1080p"`) { - t.Fatalf("expected body_json payload preserved, got %#v", record) - } + record, err := repo.GetVideoSource("gate_cam_01") + if err != nil { + t.Fatalf("GetVideoSource: %v", err) + } + if record == nil { + t.Fatal("expected video source record") + } + if record.SourceType != "rtsp" || record.Area != "东门入口" { + t.Fatalf("unexpected video source fetch: %#v", record) + } + if !strings.Contains(record.BodyJSON, `"resolution":"1080p"`) { + t.Fatalf("expected body_json payload preserved, got %#v", record) + } } diff --git a/internal/storage/audit_logs_repo.go b/internal/storage/audit_logs_repo.go index 9160feb..2084227 100644 --- a/internal/storage/audit_logs_repo.go +++ b/internal/storage/audit_logs_repo.go @@ -1,74 +1,74 @@ package storage import ( - "database/sql" - "time" + "database/sql" + "time" ) type AuditLogRecord struct { - ID int64 - Actor string - Action string - TargetType string - TargetID string - DetailsJSON string - CreatedAt string + ID int64 + Actor string + Action string + TargetType string + TargetID string + DetailsJSON string + CreatedAt string } type AuditLogsRepo struct { - db *sql.DB + db *sql.DB } func NewAuditLogsRepo(db *sql.DB) *AuditLogsRepo { - return &AuditLogsRepo{db: db} + return &AuditLogsRepo{db: db} } func (r *AuditLogsRepo) Append(entry AuditLogRecord) error { - if r == nil || r.db == nil { - return nil - } - actor := entry.Actor - if actor == "" { - actor = "system" - } - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + actor := entry.Actor + if actor == "" { + actor = "system" + } + _, err := r.db.Exec(` INSERT INTO audit_logs(actor, action, target_type, target_id, details_json, created_at) VALUES(?, ?, ?, ?, ?, ?) `, actor, entry.Action, entry.TargetType, entry.TargetID, entry.DetailsJSON, time.Now().Format(time.RFC3339)) - return err + return err } func (r *AuditLogsRepo) AppendLog(actor string, action string, targetType string, targetID string, detailsJSON string) error { - return r.Append(AuditLogRecord{ - Actor: actor, - Action: action, - TargetType: targetType, - TargetID: targetID, - DetailsJSON: detailsJSON, - }) + return r.Append(AuditLogRecord{ + Actor: actor, + Action: action, + TargetType: targetType, + TargetID: targetID, + DetailsJSON: detailsJSON, + }) } func (r *AuditLogsRepo) List() ([]AuditLogRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT id, actor, action, target_type, target_id, details_json, created_at FROM audit_logs ORDER BY id DESC `) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - var out []AuditLogRecord - for rows.Next() { - var item AuditLogRecord - if err := rows.Scan(&item.ID, &item.Actor, &item.Action, &item.TargetType, &item.TargetID, &item.DetailsJSON, &item.CreatedAt); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + var out []AuditLogRecord + for rows.Next() { + var item AuditLogRecord + if err := rows.Scan(&item.ID, &item.Actor, &item.Action, &item.TargetType, &item.TargetID, &item.DetailsJSON, &item.CreatedAt); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } diff --git a/internal/storage/audit_logs_repo_test.go b/internal/storage/audit_logs_repo_test.go index bf91a04..2a3081c 100644 --- a/internal/storage/audit_logs_repo_test.go +++ b/internal/storage/audit_logs_repo_test.go @@ -3,28 +3,28 @@ package storage import "testing" func TestAuditLogsRepoAppendsAndListsEntries(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewAuditLogsRepo(store.DB()) - if err := repo.Append(AuditLogRecord{ - Actor: "system", - Action: "config_apply", - TargetType: "device", - TargetID: "edge-01", - DetailsJSON: `{"task_id":"task-1"}`, - }); err != nil { - t.Fatalf("Append: %v", err) - } + repo := NewAuditLogsRepo(store.DB()) + if err := repo.Append(AuditLogRecord{ + Actor: "system", + Action: "config_apply", + TargetType: "device", + TargetID: "edge-01", + DetailsJSON: `{"task_id":"task-1"}`, + }); err != nil { + t.Fatalf("Append: %v", err) + } - items, err := repo.List() - if err != nil { - t.Fatalf("List: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected one audit log, got %d", len(items)) - } - if items[0].Action != "config_apply" || items[0].TargetID != "edge-01" { - t.Fatalf("unexpected audit log: %#v", items[0]) - } + items, err := repo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected one audit log, got %d", len(items)) + } + if items[0].Action != "config_apply" || items[0].TargetID != "edge-01" { + t.Fatalf("unexpected audit log: %#v", items[0]) + } } diff --git a/internal/storage/device_config_state_repo.go b/internal/storage/device_config_state_repo.go index 8b646be..c235670 100644 --- a/internal/storage/device_config_state_repo.go +++ b/internal/storage/device_config_state_repo.go @@ -1,36 +1,36 @@ package storage import ( - "database/sql" - "sort" - "time" + "database/sql" + "sort" + "time" ) type DeviceConfigStateRecord struct { - DeviceID string - TemplateName string - ProfileName string - OverlaysJSON string - ConfigID string - ConfigVersion string - LastAppliedTaskID string - UpdatedAt string + DeviceID string + TemplateName string + ProfileName string + OverlaysJSON string + ConfigID string + ConfigVersion string + LastAppliedTaskID string + UpdatedAt string } type DeviceConfigStateRepo struct { - db *sql.DB + db *sql.DB } func NewDeviceConfigStateRepo(db *sql.DB) *DeviceConfigStateRepo { - return &DeviceConfigStateRepo{db: db} + return &DeviceConfigStateRepo{db: db} } func (r *DeviceConfigStateRepo) Upsert(state DeviceConfigStateRecord) error { - if r == nil || r.db == nil { - return nil - } - now := time.Now().Format(time.RFC3339) - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` INSERT INTO device_config_state(device_id, template_name, profile_name, overlays_json, config_id, config_version, last_applied_task_id, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET @@ -42,65 +42,65 @@ ON CONFLICT(device_id) DO UPDATE SET last_applied_task_id=excluded.last_applied_task_id, updated_at=excluded.updated_at `, state.DeviceID, state.TemplateName, state.ProfileName, state.OverlaysJSON, state.ConfigID, state.ConfigVersion, state.LastAppliedTaskID, now) - return err + return err } func (r *DeviceConfigStateRepo) UpsertState(deviceID string, templateName string, profileName string, overlaysJSON string, configID string, configVersion string, lastAppliedTaskID string) error { - return r.Upsert(DeviceConfigStateRecord{ - DeviceID: deviceID, - TemplateName: templateName, - ProfileName: profileName, - OverlaysJSON: overlaysJSON, - ConfigID: configID, - ConfigVersion: configVersion, - LastAppliedTaskID: lastAppliedTaskID, - }) + return r.Upsert(DeviceConfigStateRecord{ + DeviceID: deviceID, + TemplateName: templateName, + ProfileName: profileName, + OverlaysJSON: overlaysJSON, + ConfigID: configID, + ConfigVersion: configVersion, + LastAppliedTaskID: lastAppliedTaskID, + }) } func (r *DeviceConfigStateRepo) Get(deviceID string) (*DeviceConfigStateRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - var item DeviceConfigStateRecord - err := r.db.QueryRow(` + if r == nil || r.db == nil { + return nil, nil + } + var item DeviceConfigStateRecord + err := r.db.QueryRow(` SELECT device_id, template_name, profile_name, overlays_json, config_id, config_version, last_applied_task_id, updated_at FROM device_config_state WHERE device_id = ? `, deviceID).Scan(&item.DeviceID, &item.TemplateName, &item.ProfileName, &item.OverlaysJSON, &item.ConfigID, &item.ConfigVersion, &item.LastAppliedTaskID, &item.UpdatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &item, nil + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil } func (r *DeviceConfigStateRepo) ListByProfileName(profileName string) ([]DeviceConfigStateRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT device_id, template_name, profile_name, overlays_json, config_id, config_version, last_applied_task_id, updated_at FROM device_config_state WHERE profile_name = ? ORDER BY device_id ASC `, profileName) - if err != nil { - return nil, err - } - defer rows.Close() - var items []DeviceConfigStateRecord - for rows.Next() { - var item DeviceConfigStateRecord - if err := rows.Scan(&item.DeviceID, &item.TemplateName, &item.ProfileName, &item.OverlaysJSON, &item.ConfigID, &item.ConfigVersion, &item.LastAppliedTaskID, &item.UpdatedAt); err != nil { - return nil, err - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, err - } - sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID }) - return items, nil + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeviceConfigStateRecord + for rows.Next() { + var item DeviceConfigStateRecord + if err := rows.Scan(&item.DeviceID, &item.TemplateName, &item.ProfileName, &item.OverlaysJSON, &item.ConfigID, &item.ConfigVersion, &item.LastAppliedTaskID, &item.UpdatedAt); err != nil { + return nil, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID }) + return items, nil } diff --git a/internal/storage/device_config_state_repo_test.go b/internal/storage/device_config_state_repo_test.go index 75ed2ca..7545d64 100644 --- a/internal/storage/device_config_state_repo_test.go +++ b/internal/storage/device_config_state_repo_test.go @@ -3,31 +3,31 @@ package storage import "testing" func TestDeviceConfigStateRepoUpsertsAndGetsState(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewDeviceConfigStateRepo(store.DB()) - err := repo.Upsert(DeviceConfigStateRecord{ - DeviceID: "edge-01", - TemplateName: "helmet", - ProfileName: "gate_a", - OverlaysJSON: `["night_relaxed"]`, - ConfigID: "cfg-001", - ConfigVersion: "20260427.1", - LastAppliedTaskID: "task-1", - }) - if err != nil { - t.Fatalf("Upsert: %v", err) - } + repo := NewDeviceConfigStateRepo(store.DB()) + err := repo.Upsert(DeviceConfigStateRecord{ + DeviceID: "edge-01", + TemplateName: "helmet", + ProfileName: "gate_a", + OverlaysJSON: `["night_relaxed"]`, + ConfigID: "cfg-001", + ConfigVersion: "20260427.1", + LastAppliedTaskID: "task-1", + }) + if err != nil { + t.Fatalf("Upsert: %v", err) + } - item, err := repo.Get("edge-01") - if err != nil { - t.Fatalf("Get: %v", err) - } - if item == nil { - t.Fatal("expected config state") - } - if item.ProfileName != "gate_a" || item.ConfigVersion != "20260427.1" || item.LastAppliedTaskID != "task-1" { - t.Fatalf("unexpected state: %#v", item) - } + item, err := repo.Get("edge-01") + if err != nil { + t.Fatalf("Get: %v", err) + } + if item == nil { + t.Fatal("expected config state") + } + if item.ProfileName != "gate_a" || item.ConfigVersion != "20260427.1" || item.LastAppliedTaskID != "task-1" { + t.Fatalf("unexpected state: %#v", item) + } } diff --git a/internal/storage/devices_repo.go b/internal/storage/devices_repo.go index 7167e82..3a003f7 100644 --- a/internal/storage/devices_repo.go +++ b/internal/storage/devices_repo.go @@ -1,33 +1,33 @@ package storage import ( - "database/sql" - "encoding/json" - "time" + "database/sql" + "encoding/json" + "time" - "3588AdminBackend/internal/models" + "3588AdminBackend/internal/models" ) type DevicesRepo struct { - db *sql.DB + db *sql.DB } func NewDevicesRepo(db *sql.DB) *DevicesRepo { - return &DevicesRepo{db: db} + return &DevicesRepo{db: db} } func (r *DevicesRepo) Upsert(dev *models.Device) error { - if r == nil || r.db == nil || dev == nil { - return nil - } - graphs, err := json.Marshal(dev.Graphs) - if err != nil { - return err - } - if string(graphs) == "null" { - graphs = []byte(`{}`) - } - _, err = r.db.Exec(` + if r == nil || r.db == nil || dev == nil { + return nil + } + graphs, err := json.Marshal(dev.Graphs) + if err != nil { + return err + } + if string(graphs) == "null" { + graphs = []byte(`{}`) + } + _, err = r.db.Exec(` INSERT INTO devices(device_id, hostname, ip, agent_port, media_port, alias, device_name, version, git_sha, build_id, last_seen_ms, online, graphs_json, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET @@ -45,49 +45,49 @@ ON CONFLICT(device_id) DO UPDATE SET graphs_json=excluded.graphs_json, updated_at=excluded.updated_at `, dev.DeviceID, dev.Hostname, dev.IP, dev.AgentPort, dev.MediaPort, dev.DeviceAlias, dev.DeviceName, dev.Version, dev.GitSha, dev.BuildID, dev.LastSeenMs, boolToInt(dev.Online), string(graphs), time.Now().Format(time.RFC3339)) - return err + return err } func (r *DevicesRepo) List() ([]*models.Device, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT device_id, hostname, ip, agent_port, media_port, alias, device_name, version, git_sha, build_id, last_seen_ms, online, graphs_json FROM devices ORDER BY updated_at DESC, device_id ASC `) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - var out []*models.Device - for rows.Next() { - var ( - dev models.Device - onlineInt int - graphsJSON string - ) - if err := rows.Scan(&dev.DeviceID, &dev.Hostname, &dev.IP, &dev.AgentPort, &dev.MediaPort, &dev.DeviceAlias, &dev.DeviceName, &dev.Version, &dev.GitSha, &dev.BuildID, &dev.LastSeenMs, &onlineInt, &graphsJSON); err != nil { - return nil, err - } - dev.Online = onlineInt == 1 - if graphsJSON != "" && graphsJSON != "{}" { - var graphs any - if err := json.Unmarshal([]byte(graphsJSON), &graphs); err != nil { - return nil, err - } - dev.Graphs = graphs - } - out = append(out, &dev) - } - return out, rows.Err() + var out []*models.Device + for rows.Next() { + var ( + dev models.Device + onlineInt int + graphsJSON string + ) + if err := rows.Scan(&dev.DeviceID, &dev.Hostname, &dev.IP, &dev.AgentPort, &dev.MediaPort, &dev.DeviceAlias, &dev.DeviceName, &dev.Version, &dev.GitSha, &dev.BuildID, &dev.LastSeenMs, &onlineInt, &graphsJSON); err != nil { + return nil, err + } + dev.Online = onlineInt == 1 + if graphsJSON != "" && graphsJSON != "{}" { + var graphs any + if err := json.Unmarshal([]byte(graphsJSON), &graphs); err != nil { + return nil, err + } + dev.Graphs = graphs + } + out = append(out, &dev) + } + return out, rows.Err() } func boolToInt(v bool) int { - if v { - return 1 - } - return 0 + if v { + return 1 + } + return 0 } diff --git a/internal/storage/devices_repo_test.go b/internal/storage/devices_repo_test.go index 03b05cc..0ed55a0 100644 --- a/internal/storage/devices_repo_test.go +++ b/internal/storage/devices_repo_test.go @@ -1,38 +1,38 @@ package storage import ( - "testing" + "testing" - "3588AdminBackend/internal/models" + "3588AdminBackend/internal/models" ) func TestDevicesRepoUpsertsRuntimeSnapshot(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewDevicesRepo(store.DB()) - dev := &models.Device{ - DeviceID: "edge-01", - Hostname: "orangepi5plus", - IP: "10.0.0.8", - AgentPort: 9100, - MediaPort: 9000, - DeviceName: "入口识别节点", - Online: true, - Version: "1.0.0", - } - if err := repo.Upsert(dev); err != nil { - t.Fatalf("Upsert: %v", err) - } + repo := NewDevicesRepo(store.DB()) + dev := &models.Device{ + DeviceID: "edge-01", + Hostname: "orangepi5plus", + IP: "10.0.0.8", + AgentPort: 9100, + MediaPort: 9000, + DeviceName: "入口识别节点", + Online: true, + Version: "1.0.0", + } + if err := repo.Upsert(dev); err != nil { + t.Fatalf("Upsert: %v", err) + } - saved, err := repo.List() - if err != nil { - t.Fatalf("List: %v", err) - } - if len(saved) != 1 { - t.Fatalf("expected one device snapshot, got %d", len(saved)) - } - if saved[0].DeviceID != "edge-01" || saved[0].IP != "10.0.0.8" || !saved[0].Online { - t.Fatalf("unexpected saved device snapshot: %#v", saved[0]) - } + saved, err := repo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(saved) != 1 { + t.Fatalf("expected one device snapshot, got %d", len(saved)) + } + if saved[0].DeviceID != "edge-01" || saved[0].IP != "10.0.0.8" || !saved[0].Online { + t.Fatalf("unexpected saved device snapshot: %#v", saved[0]) + } } diff --git a/internal/storage/face_gallery_repo.go b/internal/storage/face_gallery_repo.go index 635016d..92e0a7a 100644 --- a/internal/storage/face_gallery_repo.go +++ b/internal/storage/face_gallery_repo.go @@ -1,36 +1,36 @@ package storage import ( - "database/sql" - "fmt" - "os" - "path/filepath" - "time" + "database/sql" + "fmt" + "os" + "path/filepath" + "time" ) type PersonRecord struct { - ID int `json:"id"` - Name string `json:"name"` - PhotoCount int `json:"photo_count"` - Photos []string `json:"photos,omitempty"` - PhotoIDs []int `json:"photo_ids,omitempty"` - CreatedAt string `json:"created_at"` + ID int `json:"id"` + Name string `json:"name"` + PhotoCount int `json:"photo_count"` + Photos []string `json:"photos,omitempty"` + PhotoIDs []int `json:"photo_ids,omitempty"` + CreatedAt string `json:"created_at"` } type FaceGalleryRepo struct { - dbPath string + dbPath string } func NewFaceGalleryRepo(dbPath string) *FaceGalleryRepo { - return &FaceGalleryRepo{dbPath: dbPath} + return &FaceGalleryRepo{dbPath: dbPath} } func (r *FaceGalleryRepo) open() (*sql.DB, error) { - db, err := sql.Open("sqlite", r.dbPath) - if err != nil { - return nil, fmt.Errorf("open app db: %w", err) - } - _, err = db.Exec(` + db, err := sql.Open("sqlite", r.dbPath) + if err != nil { + return nil, fmt.Errorf("open app db: %w", err) + } + _, err = db.Exec(` CREATE TABLE IF NOT EXISTS face_persons ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, @@ -44,139 +44,139 @@ CREATE TABLE IF NOT EXISTS face_photos ( created_at TEXT NOT NULL DEFAULT '' ); `) - if err != nil { - db.Close() - return nil, fmt.Errorf("ensure face tables: %w", err) - } - return db, nil + if err != nil { + db.Close() + return nil, fmt.Errorf("ensure face tables: %w", err) + } + return db, nil } func (r *FaceGalleryRepo) ListPersons() ([]PersonRecord, error) { - db, err := r.open() - if err != nil { - return nil, err - } - defer db.Close() + db, err := r.open() + if err != nil { + return nil, err + } + defer db.Close() - rows, err := db.Query(`SELECT id, name, created_at FROM face_persons ORDER BY name`) - if err != nil { - return nil, err - } - defer rows.Close() + rows, err := db.Query(`SELECT id, name, created_at FROM face_persons ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() - var out []PersonRecord - for rows.Next() { - var p PersonRecord - if err := rows.Scan(&p.ID, &p.Name, &p.CreatedAt); err != nil { - continue - } - out = append(out, p) - } - // Load photos for each person - for i := range out { - photoRows, err := db.Query(`SELECT photo_path, id FROM face_photos WHERE person_id = ? ORDER BY id`, out[i].ID) - if err != nil { - continue - } - for photoRows.Next() { - var path string - var pid int - if photoRows.Scan(&path, &pid) == nil { - out[i].Photos = append(out[i].Photos, path) - out[i].PhotoIDs = append(out[i].PhotoIDs, pid) - } - } - photoRows.Close() - out[i].PhotoCount = len(out[i].Photos) - } - if out == nil { - out = make([]PersonRecord, 0) - } - return out, rows.Err() + var out []PersonRecord + for rows.Next() { + var p PersonRecord + if err := rows.Scan(&p.ID, &p.Name, &p.CreatedAt); err != nil { + continue + } + out = append(out, p) + } + // Load photos for each person + for i := range out { + photoRows, err := db.Query(`SELECT photo_path, id FROM face_photos WHERE person_id = ? ORDER BY id`, out[i].ID) + if err != nil { + continue + } + for photoRows.Next() { + var path string + var pid int + if photoRows.Scan(&path, &pid) == nil { + out[i].Photos = append(out[i].Photos, path) + out[i].PhotoIDs = append(out[i].PhotoIDs, pid) + } + } + photoRows.Close() + out[i].PhotoCount = len(out[i].Photos) + } + if out == nil { + out = make([]PersonRecord, 0) + } + return out, rows.Err() } func (r *FaceGalleryRepo) AddPerson(name string) error { - db, err := r.open() - if err != nil { - return err - } - defer db.Close() - now := time.Now().Format(time.RFC3339) - _, err = db.Exec(`INSERT INTO face_persons(name, created_at, updated_at) VALUES(?, ?, ?)`, name, now, now) - return err + db, err := r.open() + if err != nil { + return err + } + defer db.Close() + now := time.Now().Format(time.RFC3339) + _, err = db.Exec(`INSERT INTO face_persons(name, created_at, updated_at) VALUES(?, ?, ?)`, name, now, now) + return err } func (r *FaceGalleryRepo) AddPhoto(personID int, photoPath string) error { - db, err := r.open() - if err != nil { - return err - } - defer db.Close() - // Skip if already exists - var existing int - if db.QueryRow(`SELECT COUNT(*) FROM face_photos WHERE person_id = ? AND photo_path = ?`, personID, photoPath).Scan(&existing) == nil && existing > 0 { - return nil - } - now := time.Now().Format(time.RFC3339) - _, err = db.Exec(`INSERT INTO face_photos(person_id, photo_path, created_at) VALUES(?, ?, ?)`, personID, photoPath, now) - return err + db, err := r.open() + if err != nil { + return err + } + defer db.Close() + // Skip if already exists + var existing int + if db.QueryRow(`SELECT COUNT(*) FROM face_photos WHERE person_id = ? AND photo_path = ?`, personID, photoPath).Scan(&existing) == nil && existing > 0 { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err = db.Exec(`INSERT INTO face_photos(person_id, photo_path, created_at) VALUES(?, ?, ?)`, personID, photoPath, now) + return err } func (r *FaceGalleryRepo) FindOrCreatePerson(name string) (int, error) { - db, err := r.open() - if err != nil { - return 0, err - } - defer db.Close() - var id int - err = db.QueryRow(`SELECT id FROM face_persons WHERE name = ?`, name).Scan(&id) - if err == nil { - return id, nil - } - now := time.Now().Format(time.RFC3339) - res, err := db.Exec(`INSERT INTO face_persons(name, created_at, updated_at) VALUES(?, ?, ?)`, name, now, now) - if err != nil { - return 0, err - } - lid, _ := res.LastInsertId() - return int(lid), nil + db, err := r.open() + if err != nil { + return 0, err + } + defer db.Close() + var id int + err = db.QueryRow(`SELECT id FROM face_persons WHERE name = ?`, name).Scan(&id) + if err == nil { + return id, nil + } + now := time.Now().Format(time.RFC3339) + res, err := db.Exec(`INSERT INTO face_persons(name, created_at, updated_at) VALUES(?, ?, ?)`, name, now, now) + if err != nil { + return 0, err + } + lid, _ := res.LastInsertId() + return int(lid), nil } func (r *FaceGalleryRepo) DeletePerson(id int) error { - db, err := r.open() - if err != nil { - return err - } - defer db.Close() - _, err = db.Exec(`DELETE FROM face_photos WHERE person_id = ?`, id) - if err != nil { - return err - } - _, err = db.Exec(`DELETE FROM face_persons WHERE id = ?`, id) - return err + db, err := r.open() + if err != nil { + return err + } + defer db.Close() + _, err = db.Exec(`DELETE FROM face_photos WHERE person_id = ?`, id) + if err != nil { + return err + } + _, err = db.Exec(`DELETE FROM face_persons WHERE id = ?`, id) + return err } func (r *FaceGalleryRepo) RenamePerson(id int, name string) error { - db, err := r.open() - if err != nil { - return err - } - defer db.Close() - now := time.Now().Format(time.RFC3339) - _, err = db.Exec(`UPDATE face_persons SET name = ?, updated_at = ? WHERE id = ?`, name, now, id) - return err + db, err := r.open() + if err != nil { + return err + } + defer db.Close() + now := time.Now().Format(time.RFC3339) + _, err = db.Exec(`UPDATE face_persons SET name = ?, updated_at = ? WHERE id = ?`, name, now, id) + return err } func (r *FaceGalleryRepo) DeletePhoto(photoID int) error { - db, err := r.open() - if err != nil { return err } - defer db.Close() - // Get path before deleting - var path string - db.QueryRow(`SELECT photo_path FROM face_photos WHERE id = ?`, photoID).Scan(&path) - _, err = db.Exec(`DELETE FROM face_photos WHERE id = ?`, photoID) - if err != nil { return err } - // Remove file from disk - if path != "" { os.Remove(filepath.Join("dataset", path)) } - return nil + db, err := r.open() + if err != nil { return err } + defer db.Close() + // Get path before deleting + var path string + db.QueryRow(`SELECT photo_path FROM face_photos WHERE id = ?`, photoID).Scan(&path) + _, err = db.Exec(`DELETE FROM face_photos WHERE id = ?`, photoID) + if err != nil { return err } + // Remove file from disk + if path != "" { os.Remove(filepath.Join("dataset", path)) } + return nil } diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index 9e1defd..d3f6ccc 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -1,11 +1,11 @@ package storage import ( - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" ) const schema001 = ` @@ -178,39 +178,39 @@ CREATE TABLE IF NOT EXISTS audit_logs ( ` func migrate(db *sql.DB) error { - if _, err := db.Exec(schema001); err != nil { - return err - } - if err := migrateProfilePrimaryTemplateName(db); err != nil { - return err - } - return migrateProfilesToSceneTemplates(db) + if _, err := db.Exec(schema001); err != nil { + return err + } + if err := migrateProfilePrimaryTemplateName(db); err != nil { + return err + } + return migrateProfilesToSceneTemplates(db) } func migrateProfilePrimaryTemplateName(db *sql.DB) error { - hasPrimary, err := hasColumn(db, "profiles", "primary_template_name") - if err != nil { - return err - } - if hasPrimary { - return nil - } - hasLegacy, err := hasColumn(db, "profiles", "template_name") - if err != nil { - return err - } - if !hasLegacy { - return fmt.Errorf("profiles table is missing both primary_template_name and template_name") - } - tx, err := db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil { - return err - } - if _, err := tx.Exec(` + hasPrimary, err := hasColumn(db, "profiles", "primary_template_name") + if err != nil { + return err + } + if hasPrimary { + return nil + } + hasLegacy, err := hasColumn(db, "profiles", "template_name") + if err != nil { + return err + } + if !hasLegacy { + return fmt.Errorf("profiles table is missing both primary_template_name and template_name") + } + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil { + return err + } + if _, err := tx.Exec(` CREATE TABLE profiles ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -221,243 +221,243 @@ CREATE TABLE profiles ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`); err != nil { - return err - } - if _, err := tx.Exec(` + return err + } + if _, err := tx.Exec(` INSERT INTO profiles(id, name, primary_template_name, business_name, description, body_json, created_at, updated_at) SELECT id, name, template_name, business_name, description, body_json, created_at, updated_at FROM profiles_legacy_template_name `); err != nil { - return err - } - if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil { - return err - } - return tx.Commit() + return err + } + if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil { + return err + } + return tx.Commit() } func hasColumn(db *sql.DB, table string, column string) (bool, error) { - rows, err := db.Query(`PRAGMA table_info(` + table + `)`) - if err != nil { - return false, err - } - defer rows.Close() - for rows.Next() { - var ( - cid int - name string - ctype string - notnull int - dfltValue sql.NullString - pk int - ) - if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil { - return false, err - } - if name == column { - return true, nil - } - } - return false, rows.Err() + rows, err := db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dfltValue sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() } func migrateProfilesToSceneTemplates(db *sql.DB) error { - if db == nil { - return nil - } - var count int - if err := db.QueryRow(`SELECT COUNT(1) FROM scene_templates`).Scan(&count); err != nil { - return err - } - if count > 0 { - return nil - } - rows, err := db.Query(` + if db == nil { + return nil + } + var count int + if err := db.QueryRow(`SELECT COUNT(1) FROM scene_templates`).Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + rows, err := db.Query(` SELECT name, primary_template_name, business_name, description, body_json, created_at, updated_at FROM profiles ORDER BY created_at ASC, name ASC `) - if err != nil { - return err - } - defer rows.Close() + if err != nil { + return err + } + defer rows.Close() - type legacyProfile struct { - Name string - TemplateName string - BusinessName string - Description string - BodyJSON string - CreatedAt string - UpdatedAt string - } - legacy := make([]legacyProfile, 0) - for rows.Next() { - var item legacyProfile - if err := rows.Scan(&item.Name, &item.TemplateName, &item.BusinessName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { - return err - } - legacy = append(legacy, item) - } - if err := rows.Err(); err != nil { - return err - } - if len(legacy) == 0 { - return nil - } + type legacyProfile struct { + Name string + TemplateName string + BusinessName string + Description string + BodyJSON string + CreatedAt string + UpdatedAt string + } + legacy := make([]legacyProfile, 0) + for rows.Next() { + var item legacyProfile + if err := rows.Scan(&item.Name, &item.TemplateName, &item.BusinessName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { + return err + } + legacy = append(legacy, item) + } + if err := rows.Err(); err != nil { + return err + } + if len(legacy) == 0 { + return nil + } - tx, err := db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() - now := time.Now().Format(time.RFC3339) - for _, item := range legacy { - templateDoc, units, err := splitLegacyProfileDocument(item) - if err != nil { - return err - } - templateBody, err := json.MarshalIndent(templateDoc, "", " ") - if err != nil { - return err - } - createdAt := firstNonEmpty(item.CreatedAt, now) - updatedAt := firstNonEmpty(item.UpdatedAt, createdAt) - if _, err := tx.Exec(` + now := time.Now().Format(time.RFC3339) + for _, item := range legacy { + templateDoc, units, err := splitLegacyProfileDocument(item) + if err != nil { + return err + } + templateBody, err := json.MarshalIndent(templateDoc, "", " ") + if err != nil { + return err + } + createdAt := firstNonEmpty(item.CreatedAt, now) + updatedAt := firstNonEmpty(item.UpdatedAt, createdAt) + if _, err := tx.Exec(` INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO NOTHING `, item.Name, item.TemplateName, item.BusinessName, item.Description, string(append(templateBody, '\n')), createdAt, updatedAt); err != nil { - return err - } - for _, unit := range units { - if _, err := tx.Exec(` + return err + } + for _, unit := range units { + if _, err := tx.Exec(` INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(scene_template_name, name) DO NOTHING `, item.Name, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, item.Description, unit.BodyJSON, createdAt, updatedAt); err != nil { - return err - } - } - } + return err + } + } + } - return tx.Commit() + return tx.Commit() } type migratedRecognitionUnit struct { - Name string - DisplayName string - SiteName string - VideoSourceRef string - OutputChannel string - RTSPPort string - BodyJSON string + Name string + DisplayName string + SiteName string + VideoSourceRef string + OutputChannel string + RTSPPort string + BodyJSON string } func splitLegacyProfileDocument(item struct { - Name string - TemplateName string - BusinessName string - Description string - BodyJSON string - CreatedAt string - UpdatedAt string + Name string + TemplateName string + BusinessName string + Description string + BodyJSON string + CreatedAt string + UpdatedAt string }) (map[string]any, []migratedRecognitionUnit, error) { - raw := map[string]any{} - if strings.TrimSpace(item.BodyJSON) != "" { - if err := json.Unmarshal([]byte(item.BodyJSON), &raw); err != nil { - return nil, nil, err - } - } - if raw == nil { - raw = map[string]any{} - } - raw["name"] = item.Name - if strings.TrimSpace(item.TemplateName) != "" { - raw["primary_template_name"] = item.TemplateName - } - if strings.TrimSpace(item.BusinessName) != "" { - raw["business_name"] = item.BusinessName - } - if strings.TrimSpace(item.Description) != "" { - raw["description"] = item.Description - } - var units []migratedRecognitionUnit - if instances, ok := raw["instances"].([]any); ok { - for _, entry := range instances { - inst, _ := entry.(map[string]any) - if inst == nil { - continue - } - unitName := strings.TrimSpace(stringAny(inst["name"])) - if unitName == "" { - continue - } - sceneMeta, _ := inst["scene_meta"].(map[string]any) - inputBindings, _ := inst["input_bindings"].(map[string]any) - outputBindings, _ := inst["output_bindings"].(map[string]any) - videoSourceRef := strings.TrimSpace(bindingStringFromAny(inputBindings, "video_input_main", "video_source_ref")) - outputChannel := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "channel_no")) - if outputChannel == "" { - outputChannel = unitName - } - rtspPort := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "publish_rtsp_port")) - if rtspPort == "" { - rtspPort = "8555" - } - unitRaw := cloneAnyMap(inst) - body, err := json.MarshalIndent(unitRaw, "", " ") - if err != nil { - return nil, nil, err - } - units = append(units, migratedRecognitionUnit{ - Name: unitName, - DisplayName: strings.TrimSpace(stringAny(sceneMeta["display_name"])), - SiteName: strings.TrimSpace(stringAny(sceneMeta["site_name"])), - VideoSourceRef: videoSourceRef, - OutputChannel: outputChannel, - RTSPPort: rtspPort, - BodyJSON: string(append(body, '\n')), - }) - } - } - delete(raw, "instances") - return raw, units, nil + raw := map[string]any{} + if strings.TrimSpace(item.BodyJSON) != "" { + if err := json.Unmarshal([]byte(item.BodyJSON), &raw); err != nil { + return nil, nil, err + } + } + if raw == nil { + raw = map[string]any{} + } + raw["name"] = item.Name + if strings.TrimSpace(item.TemplateName) != "" { + raw["primary_template_name"] = item.TemplateName + } + if strings.TrimSpace(item.BusinessName) != "" { + raw["business_name"] = item.BusinessName + } + if strings.TrimSpace(item.Description) != "" { + raw["description"] = item.Description + } + var units []migratedRecognitionUnit + if instances, ok := raw["instances"].([]any); ok { + for _, entry := range instances { + inst, _ := entry.(map[string]any) + if inst == nil { + continue + } + unitName := strings.TrimSpace(stringAny(inst["name"])) + if unitName == "" { + continue + } + sceneMeta, _ := inst["scene_meta"].(map[string]any) + inputBindings, _ := inst["input_bindings"].(map[string]any) + outputBindings, _ := inst["output_bindings"].(map[string]any) + videoSourceRef := strings.TrimSpace(bindingStringFromAny(inputBindings, "video_input_main", "video_source_ref")) + outputChannel := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "channel_no")) + if outputChannel == "" { + outputChannel = unitName + } + rtspPort := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "publish_rtsp_port")) + if rtspPort == "" { + rtspPort = "8555" + } + unitRaw := cloneAnyMap(inst) + body, err := json.MarshalIndent(unitRaw, "", " ") + if err != nil { + return nil, nil, err + } + units = append(units, migratedRecognitionUnit{ + Name: unitName, + DisplayName: strings.TrimSpace(stringAny(sceneMeta["display_name"])), + SiteName: strings.TrimSpace(stringAny(sceneMeta["site_name"])), + VideoSourceRef: videoSourceRef, + OutputChannel: outputChannel, + RTSPPort: rtspPort, + BodyJSON: string(append(body, '\n')), + }) + } + } + delete(raw, "instances") + return raw, units, nil } func bindingStringFromAny(bindings map[string]any, slot string, field string) string { - entry, _ := bindings[slot].(map[string]any) - return strings.TrimSpace(stringAny(entry[field])) + entry, _ := bindings[slot].(map[string]any) + return strings.TrimSpace(stringAny(entry[field])) } func stringAny(v any) string { - switch vv := v.(type) { - case string: - return vv - case float64: - return fmt.Sprintf("%v", vv) - default: - return "" - } + switch vv := v.(type) { + case string: + return vv + case float64: + return fmt.Sprintf("%v", vv) + default: + return "" + } } func cloneAnyMap(in map[string]any) map[string]any { - if len(in) == 0 { - return map[string]any{} - } - out := make(map[string]any, len(in)) - for k, v := range in { - out[k] = v - } - return out + if len(in) == 0 { + return map[string]any{} + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out } func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return strings.TrimSpace(v) - } - } - return "" + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" } diff --git a/internal/storage/models_repo.go b/internal/storage/models_repo.go index e11d2c0..3076180 100644 --- a/internal/storage/models_repo.go +++ b/internal/storage/models_repo.go @@ -1,44 +1,44 @@ package storage import ( - "database/sql" - "time" + "database/sql" + "time" ) type StandardModelRecord struct { - Name string - FileName string - Version string - SHA256 string - SizeBytes int64 - ModelType string - Description string - CreatedAt string - UpdatedAt string + Name string + FileName string + Version string + SHA256 string + SizeBytes int64 + ModelType string + Description string + CreatedAt string + UpdatedAt string } type ModelsRepo struct { - db *sql.DB + db *sql.DB } func NewModelsRepo(db *sql.DB) *ModelsRepo { - return &ModelsRepo{db: db} + return &ModelsRepo{db: db} } func (r *ModelsRepo) Save(item StandardModelRecord) error { - if r == nil || r.db == nil { - return nil - } - now := time.Now().Format(time.RFC3339) - createdAt := item.CreatedAt - if createdAt == "" { - createdAt = now - } - updatedAt := item.UpdatedAt - if updatedAt == "" { - updatedAt = now - } - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + createdAt := item.CreatedAt + if createdAt == "" { + createdAt = now + } + updatedAt := item.UpdatedAt + if updatedAt == "" { + updatedAt = now + } + _, err := r.db.Exec(` INSERT INTO standard_models(name, file_name, version, sha256, size_bytes, model_type, description, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM standard_models WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET @@ -50,40 +50,40 @@ ON CONFLICT(name) DO UPDATE SET description=excluded.description, updated_at=excluded.updated_at `, item.Name, item.FileName, item.Version, item.SHA256, item.SizeBytes, item.ModelType, item.Description, item.Name, createdAt, updatedAt) - return err + return err } func (r *ModelsRepo) List() ([]StandardModelRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT name, file_name, version, sha256, size_bytes, model_type, description, created_at, updated_at FROM standard_models ORDER BY name ASC `) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - out := make([]StandardModelRecord, 0) - for rows.Next() { - var item StandardModelRecord - if err := rows.Scan( - &item.Name, - &item.FileName, - &item.Version, - &item.SHA256, - &item.SizeBytes, - &item.ModelType, - &item.Description, - &item.CreatedAt, - &item.UpdatedAt, - ); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + out := make([]StandardModelRecord, 0) + for rows.Next() { + var item StandardModelRecord + if err := rows.Scan( + &item.Name, + &item.FileName, + &item.Version, + &item.SHA256, + &item.SizeBytes, + &item.ModelType, + &item.Description, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } diff --git a/internal/storage/models_repo_test.go b/internal/storage/models_repo_test.go index 57d6d94..dd7b828 100644 --- a/internal/storage/models_repo_test.go +++ b/internal/storage/models_repo_test.go @@ -1,36 +1,36 @@ package storage import ( - "path/filepath" - "testing" + "path/filepath" + "testing" ) func TestModelsRepo_SaveAndList(t *testing.T) { - store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := NewModelsRepo(store.DB()) - err = repo.Save(StandardModelRecord{ - Name: "face_det_scrfd_500m_640", - FileName: "face_det_scrfd_500m_640_rk3588.rknn", - Version: "v1.0.0", - SHA256: "abc123", - SizeBytes: 1024, - ModelType: "face_det", - Description: "人脸检测模型", - }) - if err != nil { - t.Fatalf("Save: %v", err) - } + repo := NewModelsRepo(store.DB()) + err = repo.Save(StandardModelRecord{ + Name: "face_det_scrfd_500m_640", + FileName: "face_det_scrfd_500m_640_rk3588.rknn", + Version: "v1.0.0", + SHA256: "abc123", + SizeBytes: 1024, + ModelType: "face_det", + Description: "人脸检测模型", + }) + if err != nil { + t.Fatalf("Save: %v", err) + } - items, err := repo.List() - if err != nil { - t.Fatalf("List: %v", err) - } - if len(items) != 1 || items[0].Version != "v1.0.0" { - t.Fatalf("unexpected models: %#v", items) - } + items, err := repo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 1 || items[0].Version != "v1.0.0" { + t.Fatalf("unexpected models: %#v", items) + } } diff --git a/internal/storage/paths.go b/internal/storage/paths.go index 3cec361..59b1443 100644 --- a/internal/storage/paths.go +++ b/internal/storage/paths.go @@ -3,15 +3,15 @@ package storage import "path/filepath" type Paths struct { - DataDir string - DBPath string - LogDir string + DataDir string + DBPath string + LogDir string } func NewPaths(dataDir string) Paths { - return Paths{ - DataDir: dataDir, - DBPath: filepath.Join(dataDir, "app.db"), - LogDir: filepath.Join(dataDir, "logs"), - } + return Paths{ + DataDir: dataDir, + DBPath: filepath.Join(dataDir, "app.db"), + LogDir: filepath.Join(dataDir, "logs"), + } } diff --git a/internal/storage/resources_repo.go b/internal/storage/resources_repo.go index 151a99d..583dbb1 100644 --- a/internal/storage/resources_repo.go +++ b/internal/storage/resources_repo.go @@ -1,44 +1,44 @@ package storage import ( - "database/sql" - "time" + "database/sql" + "time" ) type StandardResourceRecord struct { - Name string - ResourceType string - Version string - SHA256 string - SizeBytes int64 - Description string - FilePath string - CreatedAt string - UpdatedAt string + Name string + ResourceType string + Version string + SHA256 string + SizeBytes int64 + Description string + FilePath string + CreatedAt string + UpdatedAt string } type ResourcesRepo struct { - db *sql.DB + db *sql.DB } func NewResourcesRepo(db *sql.DB) *ResourcesRepo { - return &ResourcesRepo{db: db} + return &ResourcesRepo{db: db} } func (r *ResourcesRepo) Save(item StandardResourceRecord) error { - if r == nil || r.db == nil { - return nil - } - now := time.Now().Format(time.RFC3339) - createdAt := item.CreatedAt - if createdAt == "" { - createdAt = now - } - updatedAt := item.UpdatedAt - if updatedAt == "" { - updatedAt = now - } - _, err := r.db.Exec(` + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + createdAt := item.CreatedAt + if createdAt == "" { + createdAt = now + } + updatedAt := item.UpdatedAt + if updatedAt == "" { + updatedAt = now + } + _, err := r.db.Exec(` INSERT INTO standard_resources(name, resource_type, version, sha256, size_bytes, description, file_path, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM standard_resources WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET @@ -50,76 +50,76 @@ ON CONFLICT(name) DO UPDATE SET file_path=excluded.file_path, updated_at=excluded.updated_at `, item.Name, item.ResourceType, item.Version, item.SHA256, item.SizeBytes, item.Description, item.FilePath, item.Name, createdAt, updatedAt) - return err + return err } func (r *ResourcesRepo) List() ([]StandardResourceRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT name, resource_type, version, sha256, size_bytes, description, file_path, created_at, updated_at FROM standard_resources ORDER BY resource_type, name `) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - out := make([]StandardResourceRecord, 0) - for rows.Next() { - var item StandardResourceRecord - if err := rows.Scan( - &item.Name, - &item.ResourceType, - &item.Version, - &item.SHA256, - &item.SizeBytes, - &item.Description, - &item.FilePath, - &item.CreatedAt, - &item.UpdatedAt, - ); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + out := make([]StandardResourceRecord, 0) + for rows.Next() { + var item StandardResourceRecord + if err := rows.Scan( + &item.Name, + &item.ResourceType, + &item.Version, + &item.SHA256, + &item.SizeBytes, + &item.Description, + &item.FilePath, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } func (r *ResourcesRepo) ListByType(resourceType string) ([]StandardResourceRecord, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT name, resource_type, version, sha256, size_bytes, description, file_path, created_at, updated_at FROM standard_resources WHERE resource_type = ? ORDER BY name `, resourceType) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - out := make([]StandardResourceRecord, 0) - for rows.Next() { - var item StandardResourceRecord - if err := rows.Scan( - &item.Name, - &item.ResourceType, - &item.Version, - &item.SHA256, - &item.SizeBytes, - &item.Description, - &item.FilePath, - &item.CreatedAt, - &item.UpdatedAt, - ); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() + out := make([]StandardResourceRecord, 0) + for rows.Next() { + var item StandardResourceRecord + if err := rows.Scan( + &item.Name, + &item.ResourceType, + &item.Version, + &item.SHA256, + &item.SizeBytes, + &item.Description, + &item.FilePath, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() } diff --git a/internal/storage/resources_repo_test.go b/internal/storage/resources_repo_test.go index 609cdc3..e97efc3 100644 --- a/internal/storage/resources_repo_test.go +++ b/internal/storage/resources_repo_test.go @@ -1,85 +1,85 @@ package storage import ( - "path/filepath" - "testing" + "path/filepath" + "testing" ) func TestResourcesRepo_SaveAndList(t *testing.T) { - store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := NewResourcesRepo(store.DB()) - err = repo.Save(StandardResourceRecord{ - Name: "face_gallery_v1", - ResourceType: "face_gallery", - Version: "auto", - SHA256: "abc123", - SizeBytes: 52428800, - Description: "默认人脸库", - FilePath: "resources/standard_resources/face_gallery_v1.db", - }) - if err != nil { - t.Fatalf("Save: %v", err) - } + repo := NewResourcesRepo(store.DB()) + err = repo.Save(StandardResourceRecord{ + Name: "face_gallery_v1", + ResourceType: "face_gallery", + Version: "auto", + SHA256: "abc123", + SizeBytes: 52428800, + Description: "默认人脸库", + FilePath: "resources/standard_resources/face_gallery_v1.db", + }) + if err != nil { + t.Fatalf("Save: %v", err) + } - items, err := repo.List() - if err != nil { - t.Fatalf("List: %v", err) - } - if len(items) != 1 || items[0].ResourceType != "face_gallery" || items[0].Version != "auto" { - t.Fatalf("unexpected resources: %#v", items) - } + items, err := repo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 1 || items[0].ResourceType != "face_gallery" || items[0].Version != "auto" { + t.Fatalf("unexpected resources: %#v", items) + } } func TestResourcesRepo_ListByType(t *testing.T) { - store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := NewResourcesRepo(store.DB()) - repo.Save(StandardResourceRecord{ - Name: "face_gallery_v1", ResourceType: "face_gallery", - Version: "v1", SHA256: "abc", - }) - repo.Save(StandardResourceRecord{ - Name: "ppe_dataset_v1", ResourceType: "dataset", - Version: "v2", SHA256: "def", - }) + repo := NewResourcesRepo(store.DB()) + repo.Save(StandardResourceRecord{ + Name: "face_gallery_v1", ResourceType: "face_gallery", + Version: "v1", SHA256: "abc", + }) + repo.Save(StandardResourceRecord{ + Name: "ppe_dataset_v1", ResourceType: "dataset", + Version: "v2", SHA256: "def", + }) - items, err := repo.ListByType("face_gallery") - if err != nil { - t.Fatalf("ListByType: %v", err) - } - if len(items) != 1 || items[0].Name != "face_gallery_v1" { - t.Fatalf("expected 1 face_gallery, got %#v", items) - } + items, err := repo.ListByType("face_gallery") + if err != nil { + t.Fatalf("ListByType: %v", err) + } + if len(items) != 1 || items[0].Name != "face_gallery_v1" { + t.Fatalf("expected 1 face_gallery, got %#v", items) + } } func TestResourcesRepo_SaveUpsert(t *testing.T) { - store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - repo := NewResourcesRepo(store.DB()) - repo.Save(StandardResourceRecord{ - Name: "face_gallery_v1", ResourceType: "face_gallery", - Version: "v1", SHA256: "abc", - }) - repo.Save(StandardResourceRecord{ - Name: "face_gallery_v1", ResourceType: "face_gallery", - Version: "v2", SHA256: "xyz", - }) + repo := NewResourcesRepo(store.DB()) + repo.Save(StandardResourceRecord{ + Name: "face_gallery_v1", ResourceType: "face_gallery", + Version: "v1", SHA256: "abc", + }) + repo.Save(StandardResourceRecord{ + Name: "face_gallery_v1", ResourceType: "face_gallery", + Version: "v2", SHA256: "xyz", + }) - items, _ := repo.List() - if len(items) != 1 || items[0].Version != "v2" || items[0].SHA256 != "xyz" { - t.Fatalf("expected upserted record v2/xyz, got %#v", items) - } + items, _ := repo.List() + if len(items) != 1 || items[0].Version != "v2" || items[0].SHA256 != "xyz" { + t.Fatalf("expected upserted record v2/xyz, got %#v", items) + } } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 5d3f600..063bd9c 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -1,51 +1,51 @@ package storage import ( - "database/sql" - "os" - "path/filepath" + "database/sql" + "os" + "path/filepath" - _ "modernc.org/sqlite" + _ "modernc.org/sqlite" ) type Store struct { - db *sql.DB + db *sql.DB } func OpenSQLite(path string) (*Store, error) { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, err - } - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, err - } - if err := migrate(db); err != nil { - _ = db.Close() - return nil, err - } - return &Store{db: db}, nil + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + if err := migrate(db); err != nil { + _ = db.Close() + return nil, err + } + return &Store{db: db}, nil } func (s *Store) Close() error { - if s == nil || s.db == nil { - return nil - } - return s.db.Close() + if s == nil || s.db == nil { + return nil + } + return s.db.Close() } func (s *Store) DB() *sql.DB { - if s == nil { - return nil - } - return s.db + if s == nil { + return nil + } + return s.db } func (s *Store) HasTable(name string) (bool, error) { - row := s.db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = ?`, name) - var count int - if err := row.Scan(&count); err != nil { - return false, err - } - return count > 0, nil + row := s.db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = ?`, name) + var count int + if err := row.Scan(&count); err != nil { + return false, err + } + return count > 0, nil } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index e0b3bf9..f465936 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -1,48 +1,48 @@ package storage import ( - "database/sql" - "path/filepath" - "testing" + "database/sql" + "path/filepath" + "testing" ) func TestSQLiteStoreBootstrapsSchema(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "app.db") - store, err := OpenSQLite(dbPath) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() + dbPath := filepath.Join(t.TempDir(), "app.db") + store, err := OpenSQLite(dbPath) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() - for _, table := range []string{ - "templates", - "profiles", - "overlays", - "integration_services", - "video_sources", - "devices", - "device_config_state", - "tasks", - "task_devices", - "audit_logs", - } { - ok, err := store.HasTable(table) - if err != nil { - t.Fatalf("HasTable(%s): %v", table, err) - } - if !ok { - t.Fatalf("expected table %s to exist", table) - } - } + for _, table := range []string{ + "templates", + "profiles", + "overlays", + "integration_services", + "video_sources", + "devices", + "device_config_state", + "tasks", + "task_devices", + "audit_logs", + } { + ok, err := store.HasTable(table) + if err != nil { + t.Fatalf("HasTable(%s): %v", table, err) + } + if !ok { + t.Fatalf("expected table %s to exist", table) + } + } } func TestSQLiteStoreMigratesLegacyProfileTemplateColumn(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "app.db") - legacyDB, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatalf("open legacy sqlite: %v", err) - } - _, err = legacyDB.Exec(` + dbPath := filepath.Join(t.TempDir(), "app.db") + legacyDB, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open legacy sqlite: %v", err) + } + _, err = legacyDB.Exec(` CREATE TABLE profiles ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -56,37 +56,37 @@ CREATE TABLE profiles ( INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at) VALUES('line_a', 'helmet', 'gate', 'desc', '{"name":"line_a","instances":[{"name":"cam1","template":"helmet"}]}', '2026-04-29T00:00:00+08:00', '2026-04-29T00:00:00+08:00'); `) - if err != nil { - t.Fatalf("seed legacy schema: %v", err) - } - _ = legacyDB.Close() + if err != nil { + t.Fatalf("seed legacy schema: %v", err) + } + _ = legacyDB.Close() - store, err := OpenSQLite(dbPath) - if err != nil { - t.Fatalf("OpenSQLite migrate legacy: %v", err) - } - defer store.Close() + store, err := OpenSQLite(dbPath) + if err != nil { + t.Fatalf("OpenSQLite migrate legacy: %v", err) + } + defer store.Close() - hasOld, err := hasColumn(store.DB(), "profiles", "template_name") - if err != nil { - t.Fatalf("has old column: %v", err) - } - if hasOld { - t.Fatal("expected legacy template_name column to be removed") - } - hasNew, err := hasColumn(store.DB(), "profiles", "primary_template_name") - if err != nil { - t.Fatalf("has new column: %v", err) - } - if !hasNew { - t.Fatal("expected primary_template_name column to exist after migration") - } - repo := NewAssetsRepo(store.DB()) - profile, err := repo.GetProfile("line_a") - if err != nil { - t.Fatalf("GetProfile after migration: %v", err) - } - if profile == nil || profile.TemplateName != "helmet" { - t.Fatalf("expected migrated primary template value, got %#v", profile) - } + hasOld, err := hasColumn(store.DB(), "profiles", "template_name") + if err != nil { + t.Fatalf("has old column: %v", err) + } + if hasOld { + t.Fatal("expected legacy template_name column to be removed") + } + hasNew, err := hasColumn(store.DB(), "profiles", "primary_template_name") + if err != nil { + t.Fatalf("has new column: %v", err) + } + if !hasNew { + t.Fatal("expected primary_template_name column to exist after migration") + } + repo := NewAssetsRepo(store.DB()) + profile, err := repo.GetProfile("line_a") + if err != nil { + t.Fatalf("GetProfile after migration: %v", err) + } + if profile == nil || profile.TemplateName != "helmet" { + t.Fatalf("expected migrated primary template value, got %#v", profile) + } } diff --git a/internal/storage/tasks_repo.go b/internal/storage/tasks_repo.go index 47351c4..16a4ce5 100644 --- a/internal/storage/tasks_repo.go +++ b/internal/storage/tasks_repo.go @@ -1,60 +1,60 @@ package storage import ( - "database/sql" - "encoding/json" - "time" + "database/sql" + "encoding/json" + "time" - "3588AdminBackend/internal/models" + "3588AdminBackend/internal/models" ) type TasksRepo struct { - db *sql.DB + db *sql.DB } func NewTasksRepo(db *sql.DB) *TasksRepo { - return &TasksRepo{db: db} + return &TasksRepo{db: db} } func (r *TasksRepo) Save(task *models.Task) error { - if r == nil || r.db == nil || task == nil { - return nil - } + if r == nil || r.db == nil || task == nil { + return nil + } - task.Mu.RLock() - payload, err := json.Marshal(task.Payload) - if err != nil { - task.Mu.RUnlock() - return err - } - status := task.Status - devices := make([]models.DeviceTaskStatus, 0, len(task.Devices)) - for _, ds := range task.Devices { - if ds == nil { - continue - } - devices = append(devices, models.DeviceTaskStatus{ - DeviceID: ds.DeviceID, - Status: ds.Status, - Progress: ds.Progress, - Error: ds.Error, - }) - } - task.Mu.RUnlock() + task.Mu.RLock() + payload, err := json.Marshal(task.Payload) + if err != nil { + task.Mu.RUnlock() + return err + } + status := task.Status + devices := make([]models.DeviceTaskStatus, 0, len(task.Devices)) + for _, ds := range task.Devices { + if ds == nil { + continue + } + devices = append(devices, models.DeviceTaskStatus{ + DeviceID: ds.DeviceID, + Status: ds.Status, + Progress: ds.Progress, + Error: ds.Error, + }) + } + task.Mu.RUnlock() - now := time.Now().Format(time.RFC3339) - finishedAt := "" - if status == models.TaskSuccess || status == models.TaskFailed { - finishedAt = now - } + now := time.Now().Format(time.RFC3339) + finishedAt := "" + if status == models.TaskSuccess || status == models.TaskFailed { + finishedAt = now + } - tx, err := r.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() - _, err = tx.Exec(` + _, err = tx.Exec(` INSERT INTO tasks(task_id, type, payload_json, status, created_at, finished_at) VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM tasks WHERE task_id = ?), ?), ?) ON CONFLICT(task_id) DO UPDATE SET @@ -63,87 +63,87 @@ ON CONFLICT(task_id) DO UPDATE SET status=excluded.status, finished_at=excluded.finished_at `, task.ID, task.Type, string(payload), string(status), task.ID, now, finishedAt) - if err != nil { - return err - } + if err != nil { + return err + } - if _, err := tx.Exec(`DELETE FROM task_devices WHERE task_id = ?`, task.ID); err != nil { - return err - } - for _, ds := range devices { - if _, err := tx.Exec(` + if _, err := tx.Exec(`DELETE FROM task_devices WHERE task_id = ?`, task.ID); err != nil { + return err + } + for _, ds := range devices { + if _, err := tx.Exec(` INSERT INTO task_devices(task_id, device_id, status, progress, error_text) VALUES(?, ?, ?, ?, ?) `, task.ID, ds.DeviceID, string(ds.Status), ds.Progress, ds.Error); err != nil { - return err - } - } + return err + } + } - return tx.Commit() + return tx.Commit() } func (r *TasksRepo) List() ([]models.Task, error) { - if r == nil || r.db == nil { - return nil, nil - } - rows, err := r.db.Query(` + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` SELECT task_id, type, payload_json, status FROM tasks ORDER BY created_at DESC, task_id DESC `) - if err != nil { - return nil, err - } - defer rows.Close() + if err != nil { + return nil, err + } + defer rows.Close() - var out []models.Task - for rows.Next() { - var ( - id, tType, payloadJSON, status string - ) - if err := rows.Scan(&id, &tType, &payloadJSON, &status); err != nil { - return nil, err - } - var payload any - if payloadJSON != "" { - if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { - return nil, err - } - } - task := models.Task{ - ID: id, - Type: tType, - Payload: payload, - Status: models.TaskStatus(status), - Devices: map[string]*models.DeviceTaskStatus{}, - } + var out []models.Task + for rows.Next() { + var ( + id, tType, payloadJSON, status string + ) + if err := rows.Scan(&id, &tType, &payloadJSON, &status); err != nil { + return nil, err + } + var payload any + if payloadJSON != "" { + if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { + return nil, err + } + } + task := models.Task{ + ID: id, + Type: tType, + Payload: payload, + Status: models.TaskStatus(status), + Devices: map[string]*models.DeviceTaskStatus{}, + } - deviceRows, err := r.db.Query(` + deviceRows, err := r.db.Query(` SELECT device_id, status, progress, error_text FROM task_devices WHERE task_id = ? ORDER BY rowid ASC `, id) - if err != nil { - return nil, err - } - for deviceRows.Next() { - var did, dsStatus, errText string - var progress float64 - if err := deviceRows.Scan(&did, &dsStatus, &progress, &errText); err != nil { - deviceRows.Close() - return nil, err - } - task.DeviceIDs = append(task.DeviceIDs, did) - task.Devices[did] = &models.DeviceTaskStatus{ - DeviceID: did, - Status: models.TaskStatus(dsStatus), - Progress: progress, - Error: errText, - } - } - deviceRows.Close() - out = append(out, task) - } - return out, rows.Err() + if err != nil { + return nil, err + } + for deviceRows.Next() { + var did, dsStatus, errText string + var progress float64 + if err := deviceRows.Scan(&did, &dsStatus, &progress, &errText); err != nil { + deviceRows.Close() + return nil, err + } + task.DeviceIDs = append(task.DeviceIDs, did) + task.Devices[did] = &models.DeviceTaskStatus{ + DeviceID: did, + Status: models.TaskStatus(dsStatus), + Progress: progress, + Error: errText, + } + } + deviceRows.Close() + out = append(out, task) + } + return out, rows.Err() } diff --git a/internal/storage/tasks_repo_test.go b/internal/storage/tasks_repo_test.go index de7d11b..97c8855 100644 --- a/internal/storage/tasks_repo_test.go +++ b/internal/storage/tasks_repo_test.go @@ -1,46 +1,46 @@ package storage import ( - "path/filepath" - "testing" + "path/filepath" + "testing" - "3588AdminBackend/internal/models" + "3588AdminBackend/internal/models" ) func openTestStore(t *testing.T) *Store { - t.Helper() - store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - return store + t.Helper() + store, err := OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + return store } func TestTasksRepoSavesAndLoadsTaskSnapshots(t *testing.T) { - store := openTestStore(t) - defer store.Close() + store := openTestStore(t) + defer store.Close() - repo := NewTasksRepo(store.DB()) - task := models.NewTask("task-1", "reload", []string{"edge-01"}, nil) - task.Status = models.TaskSuccess - task.Devices["edge-01"].Status = models.TaskSuccess - task.Devices["edge-01"].Progress = 1 + repo := NewTasksRepo(store.DB()) + task := models.NewTask("task-1", "reload", []string{"edge-01"}, nil) + task.Status = models.TaskSuccess + task.Devices["edge-01"].Status = models.TaskSuccess + task.Devices["edge-01"].Progress = 1 - if err := repo.Save(task); err != nil { - t.Fatalf("Save: %v", err) - } + if err := repo.Save(task); err != nil { + t.Fatalf("Save: %v", err) + } - items, err := repo.List() - if err != nil { - t.Fatalf("List: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected one task, got %d", len(items)) - } - if items[0].ID != "task-1" || items[0].Type != "reload" || items[0].Status != models.TaskSuccess { - t.Fatalf("unexpected task snapshot: %#v", items[0]) - } - if ds := items[0].Devices["edge-01"]; ds == nil || ds.Status != models.TaskSuccess || ds.Progress != 1 { - t.Fatalf("unexpected device snapshot: %#v", items[0].Devices["edge-01"]) - } + items, err := repo.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected one task, got %d", len(items)) + } + if items[0].ID != "task-1" || items[0].Type != "reload" || items[0].Status != models.TaskSuccess { + t.Fatalf("unexpected task snapshot: %#v", items[0]) + } + if ds := items[0].Devices["edge-01"]; ds == nil || ds.Status != models.TaskSuccess || ds.Progress != 1 { + t.Fatalf("unexpected device snapshot: %#v", items[0].Devices["edge-01"]) + } } diff --git a/internal/web/graph_node_types.go b/internal/web/graph_node_types.go index 6cec1e7..735b71b 100644 --- a/internal/web/graph_node_types.go +++ b/internal/web/graph_node_types.go @@ -1,159 +1,159 @@ package web type graphNodeParam struct { - Key string `json:"key"` - Label string `json:"label"` - Type string `json:"type"` - Step string `json:"step,omitempty"` - Placeholder string `json:"placeholder,omitempty"` - Options []string `json:"options,omitempty"` + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Step string `json:"step,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Options []string `json:"options,omitempty"` } type graphNodeTypeInfo struct { - Type string `json:"type"` - Label string `json:"label"` - Category string `json:"category"` - Icon string `json:"icon"` - Description string `json:"description"` - Defaults map[string]any `json:"defaults"` - Params []graphNodeParam `json:"params,omitempty"` + Type string `json:"type"` + Label string `json:"label"` + Category string `json:"category"` + Icon string `json:"icon"` + Description string `json:"description"` + Defaults map[string]any `json:"defaults"` + Params []graphNodeParam `json:"params,omitempty"` } func graphNodeTypesCatalog() []graphNodeTypeInfo { - return []graphNodeTypeInfo{ - nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${slot:video_input_main.url}"}, []graphNodeParam{ - textParam("url", "RTSP 地址", "${slot:video_input_main.url}"), - numberParam("fps", "输入帧率", "1"), - numberParam("width", "宽度", "1"), - numberParam("height", "高度", "1"), - boolParam("force_tcp", "强制 TCP"), - numberParam("reconnect_sec", "重连间隔秒", "1"), - }), - nodeType("input_file", "文件输入", "输入", "file", "从本地视频文件读取帧,常用于离线验证和回放。", "source", nil, []graphNodeParam{ - textParam("path", "文件路径", ""), - numberParam("fps", "回放帧率", "1"), - boolParam("loop", "循环播放"), - }), - nodeType("preprocess", "图像预处理", "处理", "adjust", "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", "filter", map[string]any{"dst_format": "rgb"}, []graphNodeParam{ - numberParam("dst_w", "输出宽度", "1"), - numberParam("dst_h", "输出高度", "1"), - selectParam("dst_format", "输出格式", []string{"rgb", "nv12", "bgr"}), - selectParam("resize_mode", "缩放方式", []string{"stretch", "letterbox"}), - boolParam("use_rga", "使用 RGA"), - textParam("rga_gate", "RGA 通道", ""), - }), - nodeType("ai_scrfd", "SCRFD 人脸检测", "AI 推理", "scan-face", "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", "filter", nil, faceDetParams()), - nodeType("ai_scrfd_sliding", "滑窗人脸检测", "AI 推理", "scan-face", "使用滑窗方式执行 SCRFD 人脸检测,适合高分辨率画面。", "filter", nil, faceDetParams()), - nodeType("ai_face_det", "人脸检测", "AI 推理", "face", "通用人脸检测节点,输出人脸框和质量信息。", "filter", nil, faceDetParams()), - nodeType("ai_face_recog", "人脸识别", "AI 推理", "face-id", "对检测到的人脸进行特征提取和人脸库匹配。", "filter", nil, []graphNodeParam{ - textParam("model_path", "模型路径", ""), - numberParam("infer_fps", "推理帧率", "0.1"), - boolParam("align", "人脸对齐"), - boolParam("emit_embedding", "输出特征"), - numberParam("max_faces", "最大人脸数", "1"), - }), - nodeType("ai_yolo", "YOLO 目标检测", "AI 推理", "target", "使用 YOLO 模型检测人员、PPE 或其他目标。", "filter", nil, []graphNodeParam{ - textParam("model_path", "模型路径", ""), - numberParam("infer_fps", "推理帧率", "0.1"), - numberParam("model_w", "模型宽度", "1"), - numberParam("model_h", "模型高度", "1"), - numberParam("conf", "置信度", "0.01"), - numberParam("nms", "NMS", "0.01"), - }), - nodeType("ai_shoe_det", "鞋靴检测", "AI 推理", "shoe", "检测鞋靴和工鞋相关目标,可配合逻辑节点判断违规。", "filter", nil, []graphNodeParam{ - textParam("model_path", "模型路径", ""), - numberParam("infer_fps", "推理帧率", "0.1"), - numberParam("conf", "置信度", "0.01"), - numberParam("nms", "NMS", "0.01"), - boolParam("append_detections", "追加检测结果"), - }), - nodeType("tracker", "目标跟踪", "处理", "route", "对检测目标分配跟踪 ID,保持跨帧目标状态。", "filter", nil, []graphNodeParam{ - textParam("mode", "跟踪模式", ""), - boolParam("per_class", "按类别跟踪"), - numberParam("high_th", "高阈值", "0.01"), - numberParam("low_th", "低阈值", "0.01"), - numberParam("iou_th", "IOU 阈值", "0.01"), - numberParam("max_age_ms", "最大保留毫秒", "1"), - }), - nodeType("logic_gate", "规则判断", "规则", "branch", "根据检测、跟踪或颜色分析结果进行业务规则判断。", "filter", nil, []graphNodeParam{ - textParam("mode", "逻辑模式", ""), - boolParam("debug", "调试输出"), - numberParam("anchor_class", "锚点类别", "1"), - numberParam("boots_class", "鞋靴类别", "1"), - numberParam("violation_class", "违规类别", "1"), - }), - nodeType("event_fusion", "事件融合", "规则", "merge", "融合多路事件,减少重复告警并形成更稳定的业务事件。", "filter", nil, nil), - nodeType("region_event", "区域事件", "规则", "region", "基于区域、越线或停留规则生成区域行为事件。", "filter", nil, nil), - nodeType("action_recog", "行为识别", "AI 推理", "activity", "识别人员行为或动作事件。", "filter", nil, nil), - nodeType("det_post", "检测后处理", "处理", "filter", "对检测结果做过滤、映射、合并或类别转换。", "filter", nil, nil), - nodeType("osd", "画面叠加", "输出", "overlay", "在视频帧上绘制检测框、文字、人脸识别和事件信息。", "filter", nil, []graphNodeParam{ - boolParam("draw_bbox", "绘制框"), - boolParam("draw_text", "绘制文字"), - boolParam("draw_face_det", "绘制人脸检测"), - boolParam("draw_face_recog", "绘制人脸识别"), - numberParam("line_width", "线宽", "0.1"), - numberParam("font_scale", "字体缩放", "0.1"), - }), - nodeType("publish", "视频输出", "输出", "broadcast", "编码并发布 RTSP、HLS 或其他视频输出。", "sink", nil, []graphNodeParam{ - selectParam("codec", "编码", []string{"h264", "h265"}), - numberParam("fps", "输出帧率", "1"), - numberParam("gop", "GOP", "1"), - numberParam("bitrate_kbps", "码率 kbps", "1"), - boolParam("use_mpp", "使用 MPP"), - boolParam("use_ffmpeg_mux", "FFmpeg 封装"), - }), - nodeType("storage", "本地存储", "输出", "database", "保存帧、事件或中间结果到本地存储。", "sink", nil, nil), - nodeType("alarm", "告警动作", "输出", "bell", "根据规则触发日志、抓图、录像片段、外部接口等动作。", "sink", nil, []graphNodeParam{ - numberParam("eval_fps", "评估帧率", "0.1"), - }), - nodeType("gate", "流控闸门", "系统", "gate", "控制流程分支或限流,保护下游节点。", "filter", nil, nil), - nodeType("zlm_http", "ZLMediaKit HTTP", "系统", "server", "提供 ZLMediaKit 相关 HTTP 文件服务能力。", "sink", nil, nil), - } + return []graphNodeTypeInfo{ + nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${slot:video_input_main.url}"}, []graphNodeParam{ + textParam("url", "RTSP 地址", "${slot:video_input_main.url}"), + numberParam("fps", "输入帧率", "1"), + numberParam("width", "宽度", "1"), + numberParam("height", "高度", "1"), + boolParam("force_tcp", "强制 TCP"), + numberParam("reconnect_sec", "重连间隔秒", "1"), + }), + nodeType("input_file", "文件输入", "输入", "file", "从本地视频文件读取帧,常用于离线验证和回放。", "source", nil, []graphNodeParam{ + textParam("path", "文件路径", ""), + numberParam("fps", "回放帧率", "1"), + boolParam("loop", "循环播放"), + }), + nodeType("preprocess", "图像预处理", "处理", "adjust", "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", "filter", map[string]any{"dst_format": "rgb"}, []graphNodeParam{ + numberParam("dst_w", "输出宽度", "1"), + numberParam("dst_h", "输出高度", "1"), + selectParam("dst_format", "输出格式", []string{"rgb", "nv12", "bgr"}), + selectParam("resize_mode", "缩放方式", []string{"stretch", "letterbox"}), + boolParam("use_rga", "使用 RGA"), + textParam("rga_gate", "RGA 通道", ""), + }), + nodeType("ai_scrfd", "SCRFD 人脸检测", "AI 推理", "scan-face", "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", "filter", nil, faceDetParams()), + nodeType("ai_scrfd_sliding", "滑窗人脸检测", "AI 推理", "scan-face", "使用滑窗方式执行 SCRFD 人脸检测,适合高分辨率画面。", "filter", nil, faceDetParams()), + nodeType("ai_face_det", "人脸检测", "AI 推理", "face", "通用人脸检测节点,输出人脸框和质量信息。", "filter", nil, faceDetParams()), + nodeType("ai_face_recog", "人脸识别", "AI 推理", "face-id", "对检测到的人脸进行特征提取和人脸库匹配。", "filter", nil, []graphNodeParam{ + textParam("model_path", "模型路径", ""), + numberParam("infer_fps", "推理帧率", "0.1"), + boolParam("align", "人脸对齐"), + boolParam("emit_embedding", "输出特征"), + numberParam("max_faces", "最大人脸数", "1"), + }), + nodeType("ai_yolo", "YOLO 目标检测", "AI 推理", "target", "使用 YOLO 模型检测人员、PPE 或其他目标。", "filter", nil, []graphNodeParam{ + textParam("model_path", "模型路径", ""), + numberParam("infer_fps", "推理帧率", "0.1"), + numberParam("model_w", "模型宽度", "1"), + numberParam("model_h", "模型高度", "1"), + numberParam("conf", "置信度", "0.01"), + numberParam("nms", "NMS", "0.01"), + }), + nodeType("ai_shoe_det", "鞋靴检测", "AI 推理", "shoe", "检测鞋靴和工鞋相关目标,可配合逻辑节点判断违规。", "filter", nil, []graphNodeParam{ + textParam("model_path", "模型路径", ""), + numberParam("infer_fps", "推理帧率", "0.1"), + numberParam("conf", "置信度", "0.01"), + numberParam("nms", "NMS", "0.01"), + boolParam("append_detections", "追加检测结果"), + }), + nodeType("tracker", "目标跟踪", "处理", "route", "对检测目标分配跟踪 ID,保持跨帧目标状态。", "filter", nil, []graphNodeParam{ + textParam("mode", "跟踪模式", ""), + boolParam("per_class", "按类别跟踪"), + numberParam("high_th", "高阈值", "0.01"), + numberParam("low_th", "低阈值", "0.01"), + numberParam("iou_th", "IOU 阈值", "0.01"), + numberParam("max_age_ms", "最大保留毫秒", "1"), + }), + nodeType("logic_gate", "规则判断", "规则", "branch", "根据检测、跟踪或颜色分析结果进行业务规则判断。", "filter", nil, []graphNodeParam{ + textParam("mode", "逻辑模式", ""), + boolParam("debug", "调试输出"), + numberParam("anchor_class", "锚点类别", "1"), + numberParam("boots_class", "鞋靴类别", "1"), + numberParam("violation_class", "违规类别", "1"), + }), + nodeType("event_fusion", "事件融合", "规则", "merge", "融合多路事件,减少重复告警并形成更稳定的业务事件。", "filter", nil, nil), + nodeType("region_event", "区域事件", "规则", "region", "基于区域、越线或停留规则生成区域行为事件。", "filter", nil, nil), + nodeType("action_recog", "行为识别", "AI 推理", "activity", "识别人员行为或动作事件。", "filter", nil, nil), + nodeType("det_post", "检测后处理", "处理", "filter", "对检测结果做过滤、映射、合并或类别转换。", "filter", nil, nil), + nodeType("osd", "画面叠加", "输出", "overlay", "在视频帧上绘制检测框、文字、人脸识别和事件信息。", "filter", nil, []graphNodeParam{ + boolParam("draw_bbox", "绘制框"), + boolParam("draw_text", "绘制文字"), + boolParam("draw_face_det", "绘制人脸检测"), + boolParam("draw_face_recog", "绘制人脸识别"), + numberParam("line_width", "线宽", "0.1"), + numberParam("font_scale", "字体缩放", "0.1"), + }), + nodeType("publish", "视频输出", "输出", "broadcast", "编码并发布 RTSP、HLS 或其他视频输出。", "sink", nil, []graphNodeParam{ + selectParam("codec", "编码", []string{"h264", "h265"}), + numberParam("fps", "输出帧率", "1"), + numberParam("gop", "GOP", "1"), + numberParam("bitrate_kbps", "码率 kbps", "1"), + boolParam("use_mpp", "使用 MPP"), + boolParam("use_ffmpeg_mux", "FFmpeg 封装"), + }), + nodeType("storage", "本地存储", "输出", "database", "保存帧、事件或中间结果到本地存储。", "sink", nil, nil), + nodeType("alarm", "告警动作", "输出", "bell", "根据规则触发日志、抓图、录像片段、外部接口等动作。", "sink", nil, []graphNodeParam{ + numberParam("eval_fps", "评估帧率", "0.1"), + }), + nodeType("gate", "流控闸门", "系统", "gate", "控制流程分支或限流,保护下游节点。", "filter", nil, nil), + nodeType("zlm_http", "ZLMediaKit HTTP", "系统", "server", "提供 ZLMediaKit 相关 HTTP 文件服务能力。", "sink", nil, nil), + } } func nodeType(t, label, category, icon, description, role string, defaults map[string]any, params []graphNodeParam) graphNodeTypeInfo { - if defaults == nil { - defaults = map[string]any{} - } - defaults["id"] = t - defaults["type"] = t - defaults["role"] = role - defaults["enable"] = true - return graphNodeTypeInfo{Type: t, Label: label, Category: category, Icon: icon, Description: description, Defaults: defaults, Params: params} + if defaults == nil { + defaults = map[string]any{} + } + defaults["id"] = t + defaults["type"] = t + defaults["role"] = role + defaults["enable"] = true + return graphNodeTypeInfo{Type: t, Label: label, Category: category, Icon: icon, Description: description, Defaults: defaults, Params: params} } func textParam(key, label, placeholder string) graphNodeParam { - return graphNodeParam{Key: key, Label: label, Type: "text", Placeholder: placeholder} + return graphNodeParam{Key: key, Label: label, Type: "text", Placeholder: placeholder} } func numberParam(key, label, step string) graphNodeParam { - return graphNodeParam{Key: key, Label: label, Type: "number", Step: step} + return graphNodeParam{Key: key, Label: label, Type: "number", Step: step} } func boolParam(key, label string) graphNodeParam { - return graphNodeParam{Key: key, Label: label, Type: "boolean"} + return graphNodeParam{Key: key, Label: label, Type: "boolean"} } func selectParam(key, label string, options []string) graphNodeParam { - return graphNodeParam{Key: key, Label: label, Type: "select", Options: options} + return graphNodeParam{Key: key, Label: label, Type: "select", Options: options} } func faceDetParams() []graphNodeParam { - return []graphNodeParam{ - textParam("model_path", "模型路径", ""), - numberParam("infer_fps", "推理帧率", "0.1"), - numberParam("model_w", "模型宽度", "1"), - numberParam("model_h", "模型高度", "1"), - numberParam("conf_thresh", "置信度", "0.01"), - numberParam("nms_thresh", "NMS", "0.01"), - numberParam("max_faces", "最大人脸数", "1"), - } + return []graphNodeParam{ + textParam("model_path", "模型路径", ""), + numberParam("infer_fps", "推理帧率", "0.1"), + numberParam("model_w", "模型宽度", "1"), + numberParam("model_h", "模型高度", "1"), + numberParam("conf_thresh", "置信度", "0.01"), + numberParam("nms_thresh", "NMS", "0.01"), + numberParam("max_faces", "最大人脸数", "1"), + } } func knownGraphNodeTypes() map[string]bool { - out := map[string]bool{} - for _, item := range graphNodeTypesCatalog() { - out[item.Type] = true - } - return out + out := map[string]bool{} + for _, item := range graphNodeTypesCatalog() { + out[item.Type] = true + } + return out } diff --git a/internal/web/ui.go b/internal/web/ui.go index f537f9f..7918d3a 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -1,4220 +1,4220 @@ package web import ( - "bytes" - "encoding/json" - "fmt" - "html/template" - "io" - "mime" - "mime/multipart" - "io/fs" - "net/http" - "net/url" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" + "bytes" + "encoding/json" + "fmt" + "html/template" + "io" + "mime" + "mime/multipart" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/service" - "3588AdminBackend/internal/storage" - "github.com/go-chi/chi/v5" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/service" + "3588AdminBackend/internal/storage" + "github.com/go-chi/chi/v5" ) type UI struct { - discovery *service.DiscoveryService - registry *service.RegistryService - agent *service.AgentClient - tasks *service.TaskService - templates *service.TemplateService - preview *service.ConfigPreviewService - stateRepo *storage.DeviceConfigStateRepo - auditRepo *storage.AuditLogsRepo - dbPath string - resourcesRepo *storage.ResourcesRepo - alarmCollector *service.AlarmCollector + discovery *service.DiscoveryService + registry *service.RegistryService + agent *service.AgentClient + tasks *service.TaskService + templates *service.TemplateService + preview *service.ConfigPreviewService + stateRepo *storage.DeviceConfigStateRepo + auditRepo *storage.AuditLogsRepo + dbPath string + resourcesRepo *storage.ResourcesRepo + alarmCollector *service.AlarmCollector - tpl *template.Template + tpl *template.Template } const ( - deviceAssignmentPreviewDevicePrefix = "demo-edge-" - deviceAssignmentPreviewDeviceCount = 8 + deviceAssignmentPreviewDevicePrefix = "demo-edge-" + deviceAssignmentPreviewDeviceCount = 8 ) type PageData struct { - Title string - ContentHTML template.HTML - Message string - Error string + Title string + ContentHTML template.HTML + Message string + Error string - DeviceCount int - OnlineCount int - OfflineCount int - FoundCount int + DeviceCount int + OnlineCount int + OfflineCount int + FoundCount int - Devices []*models.Device - DeviceRows []DeviceOverviewRow - AttentionDevices []*models.Device - Found []*models.Device - Device *models.Device - ConfigStatus *ConfigStatusView - ConfigStatusText string - ConfigStatusErr string - ConfigSources service.ConfigPreviewSources - ConfigPreview *service.ConfigPreviewResult - ResultTitle string - SelectedTemplate string - SelectedProfile string - SelectedRecognitionUnit string - SelectedAssignmentDevice string - SelectedOverlays []string - SelectedConfigID string - SelectedVersion string - Tasks []models.Task - Task *models.Task - TaskDeviceRows []TaskDeviceRow - StandardModels []storage.StandardModelRecord - ModelStatusBoard *service.ModelStatusBoard - StandardResources []storage.StandardResourceRecord - ResourceStatusBoard *service.ResourceStatusBoard - AlarmRecords []service.AlarmRecord - TodayAlarmCount int - DeviceMetrics []DeviceMetric - FaceGalleryPersons []storage.PersonRecord - Templates []service.Template - Template *service.Template - AssetTab string - AssetTemplates []service.ConfigTemplateAsset - AssetTemplate *service.ConfigTemplateAsset - AssetTemplateEditing bool - AssetProfiles []service.ConfigProfileAsset - AssetProfile *service.ConfigProfileAsset - AssetProfileEditor *service.ConfigProfileEditor - AssetProfileEditing bool - AssetProfileFormAction string - ActiveInstanceIndex int - RecognitionUnits []service.RecognitionUnitAsset - RecognitionUnit *service.RecognitionUnitAsset - RecognitionUnitEditing bool - DeviceAssignments []service.DeviceAssignmentAsset - DeviceAssignment *service.DeviceAssignmentAsset - DeviceAssignmentEditing bool - DeviceAssignmentBoard *service.DeviceAssignmentBoard - DeviceAssignmentBoardJSON template.JS - MaxUnitsPerDevice int - AssetTemplateMap map[string]service.ConfigTemplateAsset - AssetVideoSources []service.ConfigVideoSourceAsset - AssetVideoSource *service.ConfigVideoSourceAsset - AssetVideoSourceEditing bool - AssetIntegrations []service.ConfigIntegrationServiceAsset - AssetIntegration *service.ConfigIntegrationServiceAsset - AssetIntegrationEditing bool - AssetOverlays []service.ConfigOverlayAsset - AssetOverlay *service.ConfigOverlayAsset - AssetOverlayEditing bool - AssetInstanceCount int - SelectedDeviceIDs []string - SelectedDevices []*models.Device - SelectedQuery string - SelectedDevicesURL string - BatchConfigURL string - ReloadSummary string - RollbackSummary string - TemplateDraftName string - TemplateDraftDescription string - TemplateCloneSource string - TemplateCreateMode string - OverlayDraftJSON string - AuditEntries []storage.AuditLogRecord - PersistedConfig *storage.DeviceConfigStateRecord - DBPath string + Devices []*models.Device + DeviceRows []DeviceOverviewRow + AttentionDevices []*models.Device + Found []*models.Device + Device *models.Device + ConfigStatus *ConfigStatusView + ConfigStatusText string + ConfigStatusErr string + ConfigSources service.ConfigPreviewSources + ConfigPreview *service.ConfigPreviewResult + ResultTitle string + SelectedTemplate string + SelectedProfile string + SelectedRecognitionUnit string + SelectedAssignmentDevice string + SelectedOverlays []string + SelectedConfigID string + SelectedVersion string + Tasks []models.Task + Task *models.Task + TaskDeviceRows []TaskDeviceRow + StandardModels []storage.StandardModelRecord + ModelStatusBoard *service.ModelStatusBoard + StandardResources []storage.StandardResourceRecord + ResourceStatusBoard *service.ResourceStatusBoard + AlarmRecords []service.AlarmRecord + TodayAlarmCount int + DeviceMetrics []DeviceMetric + FaceGalleryPersons []storage.PersonRecord + Templates []service.Template + Template *service.Template + AssetTab string + AssetTemplates []service.ConfigTemplateAsset + AssetTemplate *service.ConfigTemplateAsset + AssetTemplateEditing bool + AssetProfiles []service.ConfigProfileAsset + AssetProfile *service.ConfigProfileAsset + AssetProfileEditor *service.ConfigProfileEditor + AssetProfileEditing bool + AssetProfileFormAction string + ActiveInstanceIndex int + RecognitionUnits []service.RecognitionUnitAsset + RecognitionUnit *service.RecognitionUnitAsset + RecognitionUnitEditing bool + DeviceAssignments []service.DeviceAssignmentAsset + DeviceAssignment *service.DeviceAssignmentAsset + DeviceAssignmentEditing bool + DeviceAssignmentBoard *service.DeviceAssignmentBoard + DeviceAssignmentBoardJSON template.JS + MaxUnitsPerDevice int + AssetTemplateMap map[string]service.ConfigTemplateAsset + AssetVideoSources []service.ConfigVideoSourceAsset + AssetVideoSource *service.ConfigVideoSourceAsset + AssetVideoSourceEditing bool + AssetIntegrations []service.ConfigIntegrationServiceAsset + AssetIntegration *service.ConfigIntegrationServiceAsset + AssetIntegrationEditing bool + AssetOverlays []service.ConfigOverlayAsset + AssetOverlay *service.ConfigOverlayAsset + AssetOverlayEditing bool + AssetInstanceCount int + SelectedDeviceIDs []string + SelectedDevices []*models.Device + SelectedQuery string + SelectedDevicesURL string + BatchConfigURL string + ReloadSummary string + RollbackSummary string + TemplateDraftName string + TemplateDraftDescription string + TemplateCloneSource string + TemplateCreateMode string + OverlayDraftJSON string + AuditEntries []storage.AuditLogRecord + PersistedConfig *storage.DeviceConfigStateRecord + DBPath string - RawJSON string - RawText string - SchemaJSON string - StateJSON string - FaceGalleryJSON string - TaskID string - DeviceIDs string - RunningTaskCount int - FailedTaskCount int - SuccessTaskCount int + RawJSON string + RawText string + SchemaJSON string + StateJSON string + FaceGalleryJSON string + TaskID string + DeviceIDs string + RunningTaskCount int + FailedTaskCount int + SuccessTaskCount int } type DeviceOverviewRow struct { - Device *models.Device - ConfigStatus *ConfigStatusView - ConfigStatusErr string + Device *models.Device + ConfigStatus *ConfigStatusView + ConfigStatusErr string } type TaskDeviceRow struct { - Device *models.Device - Status models.TaskStatus - Progress float64 - Error string + Device *models.Device + Status models.TaskStatus + Progress float64 + Error string } type ConfigStatusView struct { - OK bool `json:"ok"` - ConfigPath string `json:"config_path"` - Exists bool `json:"exists"` - Sha256 string `json:"sha256"` - Size int64 `json:"size"` - Metadata ConfigStatusMetadata `json:"metadata"` - Candidate *ConfigStatusLastGoodFile `json:"candidate"` - MediaServer ConfigStatusMediaServer `json:"media_server"` - PreviousConfig *ConfigStatusLastGoodFile `json:"previous_config"` - PreviousConfigPath string `json:"previous_config_path"` + OK bool `json:"ok"` + ConfigPath string `json:"config_path"` + Exists bool `json:"exists"` + Sha256 string `json:"sha256"` + Size int64 `json:"size"` + Metadata ConfigStatusMetadata `json:"metadata"` + Candidate *ConfigStatusLastGoodFile `json:"candidate"` + MediaServer ConfigStatusMediaServer `json:"media_server"` + PreviousConfig *ConfigStatusLastGoodFile `json:"previous_config"` + PreviousConfigPath string `json:"previous_config_path"` } type ConfigStatusMetadata struct { - ConfigID string `json:"config_id"` - ConfigVersion string `json:"config_version"` - BusinessName string `json:"business_name"` - Template string `json:"template"` - Profile string `json:"profile"` - Overlays []string `json:"overlays"` - RenderedAt string `json:"rendered_at"` - RenderedBy string `json:"rendered_by"` - InstanceName string `json:"instance_name"` - InstanceDisplayName string `json:"instance_display_name"` + ConfigID string `json:"config_id"` + ConfigVersion string `json:"config_version"` + BusinessName string `json:"business_name"` + Template string `json:"template"` + Profile string `json:"profile"` + Overlays []string `json:"overlays"` + RenderedAt string `json:"rendered_at"` + RenderedBy string `json:"rendered_by"` + InstanceName string `json:"instance_name"` + InstanceDisplayName string `json:"instance_display_name"` } type ConfigStatusMediaServer struct { - Running bool `json:"running"` - PID int `json:"pid"` - Version string `json:"version"` + Running bool `json:"running"` + PID int `json:"pid"` + Version string `json:"version"` } type ConfigStatusLastGoodFile struct { - Path string `json:"path"` - Exists bool `json:"exists"` - Sha256 string `json:"sha256"` - Metadata ConfigStatusMetadata `json:"metadata"` + Path string `json:"path"` + Exists bool `json:"exists"` + Sha256 string `json:"sha256"` + Metadata ConfigStatusMetadata `json:"metadata"` } func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService, preview ...*service.ConfigPreviewService) (*UI, error) { - tpl, err := template.New("layout").Funcs(template.FuncMap{ - "json": func(v any) string { - b, _ := json.MarshalIndent(v, "", " ") - return string(b) - }, - "rawHTML": func(v string) template.HTML { - return template.HTML(v) - }, - "hasString": func(items []string, want string) bool { - for _, item := range items { - if item == want { - return true - } - } - return false - }, - "shortHash": func(v string) string { - v = strings.TrimSpace(v) - if len(v) > 8 { - return v[:8] - } - return v - }, - "modelTypeLabel": func(v string) string { - switch strings.TrimSpace(v) { - case "face_detection": - return "人脸检测" - case "face_recognition": - return "人脸识别" - case "object_detection": - return "通用检测" - case "ppe_detection": - return "PPE检测" - case "shoe_detection": - return "工鞋检测" - case "other": - return "其他" - default: - return "-" - } - }, - "resourceTypeLabel": func(v string) string { - switch strings.TrimSpace(v) { - case "face_gallery": - return "人脸库" - case "dataset": - return "数据集" - case "calibration": - return "标定文件" - default: - return v - } - }, - "displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string { - if dev == nil { - return "-" - } - return dev.DisplayName() - }, - "displayDeviceTechnicalName": func(dev *models.Device) string { - if dev == nil { - return "" - } - if v := strings.TrimSpace(dev.TechnicalName()); v != "" { - return v - } - return "" - }, - "taskGroupLabel": func(v any) string { - switch fmt.Sprint(v) { - case "config_apply": - return "批量配置" - case "media_start", "media_restart", "media_stop": - return "批量服务" - case "reload", "rollback": - return "设备操作" - case "resource_sync_one", "resource_sync_all": - return "资源同步" - default: - return "其他任务" - } - }, - "taskActionLabel": func(v any) string { - switch fmt.Sprint(v) { - case "config_apply": - return "下发设备分配" - case "model_sync_one": - return "更新单个模型" - case "model_sync_all": - return "更新全部模型" - case "resource_sync_one": - return "同步单个资源" - case "resource_sync_all": - return "同步全部资源" - case "reload": - return "重载识别服务" - case "rollback": - return "回滚识别配置" - case "media_start": - return "启动视频分析服务" - case "media_restart": - return "重启视频分析服务" - case "media_stop": - return "停止视频分析服务" - default: - return fmt.Sprint(v) - } - }, - "taskGroupClass": func(v any) string { - switch fmt.Sprint(v) { - case "config_apply": - return "pill run" - case "model_sync_one", "model_sync_all": - return "pill warn" - case "resource_sync_one", "resource_sync_all": - return "pill warn" - case "media_start", "media_restart", "media_stop": - return "pill ok" - case "reload", "rollback": - return "pill warn" - default: - return "pill" - } - }, - "taskStatusLabel": func(v any) string { - switch fmt.Sprint(v) { - case "success": - return "成功" - case "failed": - return "失败" - case "running": - return "执行中" - default: - return "待执行" - } - }, - "taskStatusClass": func(v any) string { - switch fmt.Sprint(v) { - case "success": - return "pill ok" - case "failed": - return "pill bad" - case "running": - return "pill run" - default: - return "pill" - } - }, - "auditField": func(details string, key string) string { - var m map[string]any - if err := json.Unmarshal([]byte(details), &m); err != nil { - return "" - } - if v, ok := m[key].(string); ok { - return strings.TrimSpace(v) - } - return "" - }, - "auditActionLabel": func(v string) string { - switch strings.TrimSpace(v) { - case "config_apply": - return "下发设备分配" - case "reload": - return "重载运行配置" - case "rollback": - return "回滚运行配置" - case "media_start": - return "启动服务" - case "media_restart": - return "重启服务" - case "media_stop": - return "停止服务" - default: - return strings.TrimSpace(v) - } - }, - "auditStatusLabel": func(v string) string { - switch strings.TrimSpace(v) { - case "success": - return "成功" - case "failed": - return "失败" - case "running": - return "执行中" - case "pending": - return "待执行" - default: - return strings.TrimSpace(v) - } - }, - "ago": func(ms int64) string { - if ms <= 0 { - return "-" - } - d := time.Since(time.UnixMilli(ms)) - if d < 0 { - d = 0 - } - s := int64(d.Seconds()) - switch { - case s < 60: - return fmt.Sprintf("%d秒前", s) - case s < 3600: - return fmt.Sprintf("%d分钟前", s/60) - case s < 86400: - return fmt.Sprintf("%d小时前", s/3600) - default: - return fmt.Sprintf("%d天前", s/86400) - } - }, - "icon": func(name string) template.HTML { - return template.HTML(tablerIconSVG(name)) - }, - "slotTypeLabel": func(v string) string { - switch strings.TrimSpace(v) { - case "video_source": - return "视频源" - case "object_storage": - return "对象存储" - case "token_service": - return "认证服务" - case "alarm_service": - return "告警服务" - case "stream_publish": - return "视频输出" - default: - return strings.TrimSpace(v) - } - }, - "inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string { - if len(bindings) == 0 { - return "" - } - return strings.TrimSpace(bindings[slot].VideoSourceRef) - }, - "serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string { - if len(bindings) == 0 { - return "" - } - return strings.TrimSpace(bindings[slot].ServiceRef) - }, - "outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string { - if len(bindings) == 0 { - return "" - } - item, ok := bindings[slot] - if !ok { - return "" - } - switch strings.TrimSpace(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 "" - } - }, - }).ParseFS(uiFS, "ui/templates/*.html") - if err != nil { - return nil, err - } + tpl, err := template.New("layout").Funcs(template.FuncMap{ + "json": func(v any) string { + b, _ := json.MarshalIndent(v, "", " ") + return string(b) + }, + "rawHTML": func(v string) template.HTML { + return template.HTML(v) + }, + "hasString": func(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false + }, + "shortHash": func(v string) string { + v = strings.TrimSpace(v) + if len(v) > 8 { + return v[:8] + } + return v + }, + "modelTypeLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "face_detection": + return "人脸检测" + case "face_recognition": + return "人脸识别" + case "object_detection": + return "通用检测" + case "ppe_detection": + return "PPE检测" + case "shoe_detection": + return "工鞋检测" + case "other": + return "其他" + default: + return "-" + } + }, + "resourceTypeLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "face_gallery": + return "人脸库" + case "dataset": + return "数据集" + case "calibration": + return "标定文件" + default: + return v + } + }, + "displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string { + if dev == nil { + return "-" + } + return dev.DisplayName() + }, + "displayDeviceTechnicalName": func(dev *models.Device) string { + if dev == nil { + return "" + } + if v := strings.TrimSpace(dev.TechnicalName()); v != "" { + return v + } + return "" + }, + "taskGroupLabel": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "批量配置" + case "media_start", "media_restart", "media_stop": + return "批量服务" + case "reload", "rollback": + return "设备操作" + case "resource_sync_one", "resource_sync_all": + return "资源同步" + default: + return "其他任务" + } + }, + "taskActionLabel": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "下发设备分配" + case "model_sync_one": + return "更新单个模型" + case "model_sync_all": + return "更新全部模型" + case "resource_sync_one": + return "同步单个资源" + case "resource_sync_all": + return "同步全部资源" + case "reload": + return "重载识别服务" + case "rollback": + return "回滚识别配置" + case "media_start": + return "启动视频分析服务" + case "media_restart": + return "重启视频分析服务" + case "media_stop": + return "停止视频分析服务" + default: + return fmt.Sprint(v) + } + }, + "taskGroupClass": func(v any) string { + switch fmt.Sprint(v) { + case "config_apply": + return "pill run" + case "model_sync_one", "model_sync_all": + return "pill warn" + case "resource_sync_one", "resource_sync_all": + return "pill warn" + case "media_start", "media_restart", "media_stop": + return "pill ok" + case "reload", "rollback": + return "pill warn" + default: + return "pill" + } + }, + "taskStatusLabel": func(v any) string { + switch fmt.Sprint(v) { + case "success": + return "成功" + case "failed": + return "失败" + case "running": + return "执行中" + default: + return "待执行" + } + }, + "taskStatusClass": func(v any) string { + switch fmt.Sprint(v) { + case "success": + return "pill ok" + case "failed": + return "pill bad" + case "running": + return "pill run" + default: + return "pill" + } + }, + "auditField": func(details string, key string) string { + var m map[string]any + if err := json.Unmarshal([]byte(details), &m); err != nil { + return "" + } + if v, ok := m[key].(string); ok { + return strings.TrimSpace(v) + } + return "" + }, + "auditActionLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "config_apply": + return "下发设备分配" + case "reload": + return "重载运行配置" + case "rollback": + return "回滚运行配置" + case "media_start": + return "启动服务" + case "media_restart": + return "重启服务" + case "media_stop": + return "停止服务" + default: + return strings.TrimSpace(v) + } + }, + "auditStatusLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "success": + return "成功" + case "failed": + return "失败" + case "running": + return "执行中" + case "pending": + return "待执行" + default: + return strings.TrimSpace(v) + } + }, + "ago": func(ms int64) string { + if ms <= 0 { + return "-" + } + d := time.Since(time.UnixMilli(ms)) + if d < 0 { + d = 0 + } + s := int64(d.Seconds()) + switch { + case s < 60: + return fmt.Sprintf("%d秒前", s) + case s < 3600: + return fmt.Sprintf("%d分钟前", s/60) + case s < 86400: + return fmt.Sprintf("%d小时前", s/3600) + default: + return fmt.Sprintf("%d天前", s/86400) + } + }, + "icon": func(name string) template.HTML { + return template.HTML(tablerIconSVG(name)) + }, + "slotTypeLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "video_source": + return "视频源" + case "object_storage": + return "对象存储" + case "token_service": + return "认证服务" + case "alarm_service": + return "告警服务" + case "stream_publish": + return "视频输出" + default: + return strings.TrimSpace(v) + } + }, + "inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].VideoSourceRef) + }, + "serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].ServiceRef) + }, + "outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string { + if len(bindings) == 0 { + return "" + } + item, ok := bindings[slot] + if !ok { + return "" + } + switch strings.TrimSpace(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 "" + } + }, + }).ParseFS(uiFS, "ui/templates/*.html") + if err != nil { + return nil, err + } - previewSvc := service.NewConfigPreviewService(nil) - if len(preview) > 0 && preview[0] != nil { - previewSvc = preview[0] - } - return &UI{ - discovery: discovery, - registry: registry, - agent: agent, - tasks: tasks, - templates: templates, - preview: previewSvc, - tpl: tpl, - }, nil + previewSvc := service.NewConfigPreviewService(nil) + if len(preview) > 0 && preview[0] != nil { + previewSvc = preview[0] + } + return &UI{ + discovery: discovery, + registry: registry, + agent: agent, + tasks: tasks, + templates: templates, + preview: previewSvc, + tpl: tpl, + }, nil } func (u *UI) SetStateRepo(repo *storage.DeviceConfigStateRepo) { - if u == nil { - return - } - u.stateRepo = repo + if u == nil { + return + } + u.stateRepo = repo } func (u *UI) SetAuditRepo(repo *storage.AuditLogsRepo) { - if u == nil { - return - } - u.auditRepo = repo + if u == nil { + return + } + u.auditRepo = repo } func (u *UI) SetDBPath(path string) { - if u == nil { - return - } - u.dbPath = strings.TrimSpace(path) + if u == nil { + return + } + u.dbPath = strings.TrimSpace(path) } func (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) { - if u == nil { - return - } - u.resourcesRepo = repo + if u == nil { + return + } + u.resourcesRepo = repo } func (u *UI) SetAlarmCollector(ac *service.AlarmCollector) { - if u == nil { - return - } - u.alarmCollector = ac + if u == nil { + return + } + u.alarmCollector = ac } func tablerIconSVG(name string) string { - icons := map[string]string{ - "devices": ``, - "assets": ``, - "audit": ``, - "system": ``, - "theme": ``, - "bell": ``, - "online": ``, - "detail": ``, - "control": ``, - "device": ``, - "status": ``, - "config": ``, - "overview": ``, - "tech": ``, - "preview": ``, - "apply": ``, - "service": ``, - "task": ``, - "result": ``, - "logs": ``, - "meta": ``, - "template": ``, - "edit": ``, - "profile": ``, - "overlay": ``, - "release": ``, - "discovery": ``, - "shield": ``, - "heartbeat": ``, - } - if svg, ok := icons[name]; ok { - return svg - } - return "" + icons := map[string]string{ + "devices": ``, + "assets": ``, + "audit": ``, + "system": ``, + "theme": ``, + "bell": ``, + "online": ``, + "detail": ``, + "control": ``, + "device": ``, + "status": ``, + "config": ``, + "overview": ``, + "tech": ``, + "preview": ``, + "apply": ``, + "service": ``, + "task": ``, + "result": ``, + "logs": ``, + "meta": ``, + "template": ``, + "edit": ``, + "profile": ``, + "overlay": ``, + "release": ``, + "discovery": ``, + "shield": ``, + "heartbeat": ``, + } + if svg, ok := icons[name]; ok { + return svg + } + return "" } func (u *UI) Routes() (chi.Router, error) { - r := chi.NewRouter() + r := chi.NewRouter() - assets, err := fs.Sub(uiFS, "ui/assets") - if err != nil { - return nil, err - } - assetHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assets))) - r.Handle("/assets/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - p := req.URL.Path - w.Header().Set("Cache-Control", "no-store, max-age=0") - w.Header().Set("Pragma", "no-cache") - switch { - case strings.HasSuffix(p, ".css"): - w.Header().Set("Content-Type", "text/css; charset=utf-8") - case strings.HasSuffix(p, ".js"): - w.Header().Set("Content-Type", "text/javascript; charset=utf-8") - } - assetHandler.ServeHTTP(w, req) - })) + assets, err := fs.Sub(uiFS, "ui/assets") + if err != nil { + return nil, err + } + assetHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assets))) + r.Handle("/assets/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + p := req.URL.Path + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Header().Set("Pragma", "no-cache") + switch { + case strings.HasSuffix(p, ".css"): + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case strings.HasSuffix(p, ".js"): + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } + assetHandler.ServeHTTP(w, req) + })) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/dashboard", http.StatusFound) - }) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/dashboard", http.StatusFound) + }) - r.Get("/dashboard", u.pageDashboard) - r.Get("/devices", u.pageDevices) - r.Get("/devices/{id}/control", u.pageDeviceControl) - r.Get("/plans", u.redirectPlansToSceneTemplates) - r.Get("/plans/{name}", u.redirectPlanToSceneTemplate) - r.Get("/scene-templates", u.pagePlans) - r.Get("/scene-templates/{name}", u.pagePlan) - r.Post("/scene-templates/create", u.actionPlanCreate) - r.Post("/scene-templates/{name}", u.actionPlanSave) - r.Post("/scene-templates/{name}/delete", u.actionPlanDelete) - r.Get("/scene-templates/{name}/export", u.pagePlanExport) - r.Get("/recognition-units", u.pageRecognitionUnits) - r.Post("/recognition-units", u.actionRecognitionUnitSave) - r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete) - r.Get("/device-assignments", u.pageDeviceAssignments) - r.Post("/device-assignments", u.actionDeviceAssignmentSave) - r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete) - r.Get("/assets", u.pageAssets) - r.Get("/assets/video-sources", u.pageAssetVideoSources) - r.Post("/assets/video-sources", u.actionAssetVideoSourceSave) - r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete) - r.Get("/assets/templates", u.pageAssetTemplates) - r.Post("/assets/templates/create", u.actionAssetTemplateCreate) - r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone) - r.Get("/assets/templates/{name}", u.pageAssetTemplate) - r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename) - r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete) - r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph) - r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave) - r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport) - r.Get("/assets/profiles", u.redirectAssetProfilesToPlans) - r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan) - r.Post("/assets/profiles/{name}", u.actionPlanSave) - r.Get("/assets/profiles/{name}/export", u.pagePlanExport) - r.Get("/assets/integrations", u.pageAssetIntegrations) - r.Post("/assets/integrations", u.actionAssetIntegrationSave) - r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete) - r.Get("/assets/overlays", u.pageAssetOverlays) - r.Post("/assets/overlays", u.actionAssetOverlaySave) - r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete) - r.Get("/assets/overlays/{name}", u.pageAssetOverlay) - r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) - r.Get("/audit", u.pageAudit) - r.Get("/system", u.pageSystem) - r.Get("/system/db-backup", u.pageSystemDBBackup) - r.Get("/resources", u.pageResources) - r.Post("/system/db-restore", u.actionSystemDBRestore) - r.Get("/api/graph-node-types", u.apiGraphNodeTypes) - r.Get("/device-config", u.pageDeviceConfig) - r.Get("/device-config/{id}", u.pageDeviceConfigDetail) - r.Get("/devices-add", u.pageDeviceAdd) - r.Post("/devices-add", u.actionDeviceAdd) - r.Post("/devices/batch-action", u.actionDevicesBatchAction) - r.Get("/devices/batch-config", u.pageDeviceBatchConfig) - r.Post("/devices/batch-config", u.actionDeviceBatchConfig) - r.Post("/discovery/search", u.actionDiscoverySearch) - r.Get("/devices/{id}", u.pageDevice) - r.Post("/devices/{id}/alias", u.actionDeviceAliasSave) - r.Post("/devices/{id}/action", u.actionDeviceAction) - r.Get("/devices/{id}/logs", u.pageDeviceLogs) - r.Get("/devices/{id}/graphs", u.pageDeviceGraphs) - r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply) - r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply) - r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI) - r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly) - r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview) - r.Post("/devices/{id}/config-preview", u.actionDeviceConfigPreview) - r.Post("/devices/{id}/config-candidate", u.actionDeviceConfigCandidate) - r.Post("/devices/{id}/config-candidate/apply", u.actionDeviceConfigCandidateApply) - r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan) - r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply) - r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload) - r.Post("/devices/{id}/face-gallery/reload", u.actionDeviceFaceGalleryReload) - r.Post("/devices/{id}/models/upload", u.actionDeviceModelUpload) - r.Post("/devices/{id}/media-server/configs/upload", u.actionDeviceMediaServerConfigUpload) - r.Post("/devices/{id}/media-server/configs/upload-batch", u.actionDeviceMediaServerConfigUploadBatch) + r.Get("/dashboard", u.pageDashboard) + r.Get("/devices", u.pageDevices) + r.Get("/devices/{id}/control", u.pageDeviceControl) + r.Get("/plans", u.redirectPlansToSceneTemplates) + r.Get("/plans/{name}", u.redirectPlanToSceneTemplate) + r.Get("/scene-templates", u.pagePlans) + r.Get("/scene-templates/{name}", u.pagePlan) + r.Post("/scene-templates/create", u.actionPlanCreate) + r.Post("/scene-templates/{name}", u.actionPlanSave) + r.Post("/scene-templates/{name}/delete", u.actionPlanDelete) + r.Get("/scene-templates/{name}/export", u.pagePlanExport) + r.Get("/recognition-units", u.pageRecognitionUnits) + r.Post("/recognition-units", u.actionRecognitionUnitSave) + r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete) + r.Get("/device-assignments", u.pageDeviceAssignments) + r.Post("/device-assignments", u.actionDeviceAssignmentSave) + r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete) + r.Get("/assets", u.pageAssets) + r.Get("/assets/video-sources", u.pageAssetVideoSources) + r.Post("/assets/video-sources", u.actionAssetVideoSourceSave) + r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete) + r.Get("/assets/templates", u.pageAssetTemplates) + r.Post("/assets/templates/create", u.actionAssetTemplateCreate) + r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone) + r.Get("/assets/templates/{name}", u.pageAssetTemplate) + r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename) + r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete) + r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph) + r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave) + r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport) + r.Get("/assets/profiles", u.redirectAssetProfilesToPlans) + r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan) + r.Post("/assets/profiles/{name}", u.actionPlanSave) + r.Get("/assets/profiles/{name}/export", u.pagePlanExport) + r.Get("/assets/integrations", u.pageAssetIntegrations) + r.Post("/assets/integrations", u.actionAssetIntegrationSave) + r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete) + r.Get("/assets/overlays", u.pageAssetOverlays) + r.Post("/assets/overlays", u.actionAssetOverlaySave) + r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete) + r.Get("/assets/overlays/{name}", u.pageAssetOverlay) + r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) + r.Get("/audit", u.pageAudit) + r.Get("/system", u.pageSystem) + r.Get("/system/db-backup", u.pageSystemDBBackup) + r.Get("/resources", u.pageResources) + r.Post("/system/db-restore", u.actionSystemDBRestore) + r.Get("/api/graph-node-types", u.apiGraphNodeTypes) + r.Get("/device-config", u.pageDeviceConfig) + r.Get("/device-config/{id}", u.pageDeviceConfigDetail) + r.Get("/devices-add", u.pageDeviceAdd) + r.Post("/devices-add", u.actionDeviceAdd) + r.Post("/devices/batch-action", u.actionDevicesBatchAction) + r.Get("/devices/batch-config", u.pageDeviceBatchConfig) + r.Post("/devices/batch-config", u.actionDeviceBatchConfig) + r.Post("/discovery/search", u.actionDiscoverySearch) + r.Get("/devices/{id}", u.pageDevice) + r.Post("/devices/{id}/alias", u.actionDeviceAliasSave) + r.Post("/devices/{id}/action", u.actionDeviceAction) + r.Get("/devices/{id}/logs", u.pageDeviceLogs) + r.Get("/devices/{id}/graphs", u.pageDeviceGraphs) + r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply) + r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply) + r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI) + r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly) + r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview) + r.Post("/devices/{id}/config-preview", u.actionDeviceConfigPreview) + r.Post("/devices/{id}/config-candidate", u.actionDeviceConfigCandidate) + r.Post("/devices/{id}/config-candidate/apply", u.actionDeviceConfigCandidateApply) + r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan) + r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply) + r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload) + r.Post("/devices/{id}/face-gallery/reload", u.actionDeviceFaceGalleryReload) + r.Post("/devices/{id}/models/upload", u.actionDeviceModelUpload) + r.Post("/devices/{id}/media-server/configs/upload", u.actionDeviceMediaServerConfigUpload) + r.Post("/devices/{id}/media-server/configs/upload-batch", u.actionDeviceMediaServerConfigUploadBatch) - r.Get("/tasks", u.pageTasks) - r.Post("/tasks", u.actionCreateTask) - r.Get("/tasks/{id}", u.pageTask) + r.Get("/tasks", u.pageTasks) + r.Post("/tasks", u.actionCreateTask) + r.Get("/tasks/{id}", u.pageTask) - r.Get("/templates", u.pageTemplates) - r.Get("/templates/{name}", u.pageTemplate) - r.Get("/models", u.pageModels) - r.Post("/models/sync", u.actionModelSync) - r.Post("/resources/sync", u.actionResourceSync) - r.Get("/diagnostics", u.pageDiagnostics) - r.Get("/alarms", u.pageAlarms) - r.Get("/face-gallery", u.pageFaceGallery) - r.Get("/face-photo/*", u.serveFacePhoto) - r.Post("/face-gallery/import", u.actionFaceGalleryImport) - r.Post("/face-gallery/build", u.actionFaceGalleryBuild) - r.Post("/face-gallery/add", u.actionFaceGalleryAdd) - r.Post("/face-gallery/delete", u.actionFaceGalleryDelete) - r.Post("/face-gallery/rename", u.actionFaceGalleryRename) - r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto) - r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto) - r.Get("/monitor", u.pageMonitor) - r.Get("/hls/*", u.proxyHLS) - r.Get("/api/monitor/channels", u.apiMonitorChannels) - r.Get("/api/device-metrics", u.apiDeviceMetrics) - r.Get("/recognition", u.pageRecognition) - r.Get("/logs", u.pageLogs) + r.Get("/templates", u.pageTemplates) + r.Get("/templates/{name}", u.pageTemplate) + r.Get("/models", u.pageModels) + r.Post("/models/sync", u.actionModelSync) + r.Post("/resources/sync", u.actionResourceSync) + r.Get("/diagnostics", u.pageDiagnostics) + r.Get("/alarms", u.pageAlarms) + r.Get("/face-gallery", u.pageFaceGallery) + r.Get("/face-photo/*", u.serveFacePhoto) + r.Post("/face-gallery/import", u.actionFaceGalleryImport) + r.Post("/face-gallery/build", u.actionFaceGalleryBuild) + r.Post("/face-gallery/add", u.actionFaceGalleryAdd) + r.Post("/face-gallery/delete", u.actionFaceGalleryDelete) + r.Post("/face-gallery/rename", u.actionFaceGalleryRename) + r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto) + r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto) + r.Get("/monitor", u.pageMonitor) + r.Get("/hls/*", u.proxyHLS) + r.Get("/api/monitor/channels", u.apiMonitorChannels) + r.Get("/api/device-metrics", u.apiDeviceMetrics) + r.Get("/recognition", u.pageRecognition) + r.Get("/logs", u.pageLogs) - r.Get("/api", u.pageAPIConsole) + r.Get("/api", u.pageAPIConsole) - return r, nil + return r, nil } func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) { - var buf bytes.Buffer - if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - data.ContentHTML = template.HTML(buf.String()) + var buf bytes.Buffer + if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + data.ContentHTML = template.HTML(buf.String()) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-store, max-age=0") - w.Header().Set("Pragma", "no-cache") - if err := u.tpl.ExecuteTemplate(w, "layout", data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Header().Set("Pragma", "no-cache") + if err := u.tpl.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } func (u *UI) findDevice(id string) (*models.Device, bool) { - u.ensureDevicesLoaded() - devices := u.registry.GetDevices() - for _, d := range devices { - if d.DeviceID == id { - return d, true - } - } - if u.discovery != nil { - _, _ = u.discovery.SearchDefault() - devices = u.registry.GetDevices() - for _, d := range devices { - if d.DeviceID == id { - return d, true - } - } - } - return nil, false + u.ensureDevicesLoaded() + devices := u.registry.GetDevices() + for _, d := range devices { + if d.DeviceID == id { + return d, true + } + } + if u.discovery != nil { + _, _ = u.discovery.SearchDefault() + devices = u.registry.GetDevices() + for _, d := range devices { + if d.DeviceID == id { + return d, true + } + } + } + return nil, false } func (u *UI) ensureDevicesLoaded() { - if u.registry == nil || u.discovery == nil { - return - } - devices := u.registry.GetDevices() - if len(devices) > 0 { - // Check if any device is online; if not, try discovery to refresh - hasOnline := false - for _, d := range devices { - if d.Online { - hasOnline = true - break - } - } - if hasOnline { - return - } - } - _, _ = u.discovery.SearchDefault() - if len(u.registry.GetDevices()) == 0 { - _, _ = u.discovery.SearchDefault() - } + if u.registry == nil || u.discovery == nil { + return + } + devices := u.registry.GetDevices() + if len(devices) > 0 { + // Check if any device is online; if not, try discovery to refresh + hasOnline := false + for _, d := range devices { + if d.Online { + hasOnline = true + break + } + } + if hasOnline { + return + } + } + _, _ = u.discovery.SearchDefault() + if len(u.registry.GetDevices()) == 0 { + _, _ = u.discovery.SearchDefault() + } } func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) { - data := u.deviceOverviewPageData(r, nil, "") - if u.tasks != nil { - for _, task := range u.tasks.ListTasks() { - switch task.Status { - case models.TaskRunning: - data.RunningTaskCount++ - case models.TaskFailed: - data.FailedTaskCount++ - case models.TaskSuccess: - data.SuccessTaskCount++ - } - } - } - data.Title = "总览" - if u.alarmCollector != nil { - data.AlarmRecords = u.alarmCollector.GetRecent(5) - all := u.alarmCollector.GetRecent(9999) - today := time.Now().Format("2006-01-02") - for _, a := range all { - if strings.HasPrefix(a.Timestamp, today) { - data.TodayAlarmCount++ - } - } - } - data.Tasks = nil - if u.tasks != nil { - data.Tasks = u.tasks.ListTasks() - } - data.AttentionDevices = nil - // Load device metrics - if u.agent != nil { - for _, dev := range data.Devices { - if dev == nil || !dev.Online { continue } - body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) - if err != nil || code != 200 { continue } - var m struct { - CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"` - Memory struct{ TotalKB uint64 `json:"total_kb"`; AvailableKB uint64 `json:"available_kb"` } `json:"memory"` - NPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"npu"` - } - json.Unmarshal(body, &m) - var mem float64 - if m.Memory.TotalKB > 0 { mem = float64(m.Memory.TotalKB-m.Memory.AvailableKB) / float64(m.Memory.TotalKB) * 100 } - data.DeviceMetrics = append(data.DeviceMetrics, DeviceMetric{ - Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: mem, NPU: m.NPU.UsagePct, - }) - } - } - for _, dev := range data.Devices { - if dev != nil && !dev.Online { - data.AttentionDevices = append(data.AttentionDevices, dev) - } - } - u.render(w, r, "dashboard", data) + data := u.deviceOverviewPageData(r, nil, "") + if u.tasks != nil { + for _, task := range u.tasks.ListTasks() { + switch task.Status { + case models.TaskRunning: + data.RunningTaskCount++ + case models.TaskFailed: + data.FailedTaskCount++ + case models.TaskSuccess: + data.SuccessTaskCount++ + } + } + } + data.Title = "总览" + if u.alarmCollector != nil { + data.AlarmRecords = u.alarmCollector.GetRecent(5) + all := u.alarmCollector.GetRecent(9999) + today := time.Now().Format("2006-01-02") + for _, a := range all { + if strings.HasPrefix(a.Timestamp, today) { + data.TodayAlarmCount++ + } + } + } + data.Tasks = nil + if u.tasks != nil { + data.Tasks = u.tasks.ListTasks() + } + data.AttentionDevices = nil + // Load device metrics + if u.agent != nil { + for _, dev := range data.Devices { + if dev == nil || !dev.Online { continue } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil) + if err != nil || code != 200 { continue } + var m struct { + CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"` + Memory struct{ TotalKB uint64 `json:"total_kb"`; AvailableKB uint64 `json:"available_kb"` } `json:"memory"` + NPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"npu"` + } + json.Unmarshal(body, &m) + var mem float64 + if m.Memory.TotalKB > 0 { mem = float64(m.Memory.TotalKB-m.Memory.AvailableKB) / float64(m.Memory.TotalKB) * 100 } + data.DeviceMetrics = append(data.DeviceMetrics, DeviceMetric{ + Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: mem, NPU: m.NPU.UsagePct, + }) + } + } + for _, dev := range data.Devices { + if dev != nil && !dev.Online { + data.AttentionDevices = append(data.AttentionDevices, dev) + } + } + u.render(w, r, "dashboard", data) } func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) { - u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "")) + u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "")) } func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) { - u.render(w, r, "device_add", PageData{Title: "新增设备"}) + u.render(w, r, "device_add", PageData{Title: "新增设备"}) } func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) { - u.ensureDevicesLoaded() - u.render(w, r, "device_config", PageData{ - Title: "设备配置入口", - Devices: u.registry.GetDevices(), - }) + u.ensureDevicesLoaded() + u.render(w, r, "device_config", PageData{ + Title: "设备配置入口", + Devices: u.registry.GetDevices(), + }) } func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) + id := chi.URLParam(r, "id") + http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) } func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - deviceID := strings.TrimSpace(r.FormValue("device_id")) - deviceName := strings.TrimSpace(r.FormValue("device_name")) - ip := strings.TrimSpace(r.FormValue("ip")) - agentPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("agent_port"))) - mediaPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("media_port"))) + _ = r.ParseForm() + deviceID := strings.TrimSpace(r.FormValue("device_id")) + deviceName := strings.TrimSpace(r.FormValue("device_name")) + ip := strings.TrimSpace(r.FormValue("ip")) + agentPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("agent_port"))) + mediaPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("media_port"))) - if deviceID == "" || ip == "" { - u.render(w, r, "device_add", PageData{Title: "新增设备", Error: "节点标识和 IP 不能为空"}) - return - } - if agentPort == 0 { - agentPort = 9100 - } - if mediaPort == 0 { - mediaPort = 9000 - } + if deviceID == "" || ip == "" { + u.render(w, r, "device_add", PageData{Title: "新增设备", Error: "节点标识和 IP 不能为空"}) + return + } + if agentPort == 0 { + agentPort = 9100 + } + if mediaPort == 0 { + mediaPort = 9000 + } - dev := &models.Device{ - DeviceID: deviceID, - DeviceName: deviceName, - IP: ip, - AgentPort: agentPort, - MediaPort: mediaPort, - Online: true, - LastSeenMs: time.Now().UnixMilli(), - } - u.registry.UpdateDevice(dev) - http.Redirect(w, r, "/ui/devices", http.StatusFound) + dev := &models.Device{ + DeviceID: deviceID, + DeviceName: deviceName, + IP: ip, + AgentPort: agentPort, + MediaPort: mediaPort, + Online: true, + LastSeenMs: time.Now().UnixMilli(), + } + u.registry.UpdateDevice(dev) + http.Redirect(w, r, "/ui/devices", http.StatusFound) } func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - timeoutMs, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_ms"))) - if timeoutMs <= 0 { - timeoutMs = 1200 - } + _ = r.ParseForm() + timeoutMs, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_ms"))) + if timeoutMs <= 0 { + timeoutMs = 1200 + } - found, err := u.discovery.Search(timeoutMs) - devices := u.registry.GetDevices() - online := 0 - for _, d := range devices { - if d.Online { - online++ - } - } - data := PageData{Title: "设备", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online} - if err != nil { - data.Error = err.Error() - } - u.render(w, r, "devices", data) + found, err := u.discovery.Search(timeoutMs) + devices := u.registry.GetDevices() + online := 0 + for _, d := range devices { + if d.Online { + online++ + } + } + data := PageData{Title: "设备", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "devices", data) } func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - action := strings.TrimSpace(r.FormValue("action")) - deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) - if len(deviceIDs) == 0 { - u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备")) - return - } + _ = r.ParseForm() + action := strings.TrimSpace(r.FormValue("action")) + deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) + if len(deviceIDs) == 0 { + u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备")) + return + } - typeStr := "" - switch action { - case "media_start", "media_restart", "media_stop", "reload", "rollback": - typeStr = action - default: - u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action)) - return - } + typeStr := "" + switch action { + case "media_start", "media_restart", "media_stop", "reload", "rollback": + typeStr = action + default: + u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action)) + return + } - if u.tasks == nil { - http.Error(w, "task service not initialized", http.StatusInternalServerError) - return - } + if u.tasks == nil { + http.Error(w, "task service not initialized", http.StatusInternalServerError) + return + } - var payload any - if typeStr == "media_start" || typeStr == "media_restart" { - cfgName := strings.TrimSpace(r.FormValue("config")) - if cfgName != "" { - payload = map[string]any{"config": cfgName} - } - } + var payload any + if typeStr == "media_start" || typeStr == "media_restart" { + cfgName := strings.TrimSpace(r.FormValue("config")) + if cfgName != "" { + payload = map[string]any{"config": cfgName} + } + } - task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) - if err != nil { - u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error())) - return - } + task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) + if err != nil { + u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error())) + return + } - http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) } func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { - data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"])) - u.render(w, r, "device_batch_config", data) + data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"])) + u.render(w, r, "device_batch_config", data) } func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) - data := u.deviceBatchConfigPageData(r, selectedIDs) + _ = r.ParseForm() + selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) + data := u.deviceBatchConfigPageData(r, selectedIDs) - if len(selectedIDs) == 0 { - data.Error = "请先选择需要下发分配的设备" - u.render(w, r, "device_batch_config", data) - return - } - if u.tasks == nil { - data.Error = "task service not initialized" - u.render(w, r, "device_batch_config", data) - return - } - configs := make(map[string]any, len(selectedIDs)) - for _, deviceID := range selectedIDs { - preview, err := u.preview.RenderDeviceAssignment(deviceID) - if err != nil { - data.Error = err.Error() - u.render(w, r, "device_batch_config", data) - return - } - if data.ConfigPreview == nil { - data.ConfigPreview = preview - } - var configDoc any - if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { - data.Error = "生成配置 JSON 无效: " + err.Error() - u.render(w, r, "device_batch_config", data) - return - } - configs[deviceID] = configDoc - } + if len(selectedIDs) == 0 { + data.Error = "请先选择需要下发分配的设备" + u.render(w, r, "device_batch_config", data) + return + } + if u.tasks == nil { + data.Error = "task service not initialized" + u.render(w, r, "device_batch_config", data) + return + } + configs := make(map[string]any, len(selectedIDs)) + for _, deviceID := range selectedIDs { + preview, err := u.preview.RenderDeviceAssignment(deviceID) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device_batch_config", data) + return + } + if data.ConfigPreview == nil { + data.ConfigPreview = preview + } + var configDoc any + if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { + data.Error = "生成配置 JSON 无效: " + err.Error() + u.render(w, r, "device_batch_config", data) + return + } + configs[deviceID] = configDoc + } - task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs}) - if err != nil { - data.Error = err.Error() - u.render(w, r, "device_batch_config", data) - return - } + task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs}) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device_batch_config", data) + return + } - http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) } func (u *UI) pageDevice(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, "device", u.deviceDetailPageData(dev)) + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + u.render(w, r, "device", u.deviceDetailPageData(dev)) } func (u *UI) deviceDetailPageData(dev *models.Device) PageData { - data := u.deviceControlPageData(dev) - data.Title = "设备详情" - if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil { - if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil { - data.PersistedConfig = state - } - } - if u.preview != nil { - if profiles, err := u.preview.ListProfileAssets(); err == nil { - data.AssetProfiles = profiles - selectedProfile := "" - if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" { - selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile) - } else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" { - selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName) - } - if selectedProfile == "" && len(profiles) > 0 { - selectedProfile = profiles[0].Name - } - data.SelectedProfile = selectedProfile - for i := range profiles { - if strings.TrimSpace(profiles[i].Name) == selectedProfile { - data.AssetProfile = &profiles[i] - data.SelectedTemplate = profileAssetTemplate(&profiles[i]) - break - } - } - if data.AssetProfile == nil && len(profiles) > 0 { - data.AssetProfile = &profiles[0] - data.SelectedProfile = profiles[0].Name - data.SelectedTemplate = profileAssetTemplate(&profiles[0]) - } - } else if data.Error == "" { - data.Error = err.Error() - } - if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { - data.DeviceAssignment = assignment - data.SelectedAssignmentDevice = assignment.DeviceID - data.SelectedProfile = assignment.ProfileName - } - } - return data + data := u.deviceControlPageData(dev) + data.Title = "设备详情" + if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil { + if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil { + data.PersistedConfig = state + } + } + if u.preview != nil { + if profiles, err := u.preview.ListProfileAssets(); err == nil { + data.AssetProfiles = profiles + selectedProfile := "" + if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" { + selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile) + } else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" { + selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName) + } + if selectedProfile == "" && len(profiles) > 0 { + selectedProfile = profiles[0].Name + } + data.SelectedProfile = selectedProfile + for i := range profiles { + if strings.TrimSpace(profiles[i].Name) == selectedProfile { + data.AssetProfile = &profiles[i] + data.SelectedTemplate = profileAssetTemplate(&profiles[i]) + break + } + } + if data.AssetProfile == nil && len(profiles) > 0 { + data.AssetProfile = &profiles[0] + data.SelectedProfile = profiles[0].Name + data.SelectedTemplate = profileAssetTemplate(&profiles[0]) + } + } else if data.Error == "" { + data.Error = err.Error() + } + if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { + data.DeviceAssignment = assignment + data.SelectedAssignmentDevice = assignment.DeviceID + data.SelectedProfile = assignment.ProfileName + } + } + return data } func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) + id := chi.URLParam(r, "id") + http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound) } func (u *UI) actionDeviceAction(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() - action := strings.TrimSpace(r.FormValue("action")) + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + action := strings.TrimSpace(r.FormValue("action")) - method := "POST" - path := "" - switch action { - case "reload": - path = "/v1/media-server/reload" - case "rollback": - path = "/v1/media-server/rollback" - case "media_start": - path = "/v1/media-server/start" - case "media_restart": - path = "/v1/media-server/restart" - case "media_stop": - path = "/v1/media-server/stop" - case "media_status": - method = "GET" - path = "/v1/media-server/status" - case "info": - method = "GET" - path = "/v1/info" - default: - http.Error(w, "unknown action", http.StatusBadRequest) - return - } + method := "POST" + path := "" + switch action { + case "reload": + path = "/v1/media-server/reload" + case "rollback": + path = "/v1/media-server/rollback" + case "media_start": + path = "/v1/media-server/start" + case "media_restart": + path = "/v1/media-server/restart" + case "media_stop": + path = "/v1/media-server/stop" + case "media_status": + method = "GET" + path = "/v1/media-server/status" + case "info": + method = "GET" + path = "/v1/info" + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } - body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil) - msg := fmt.Sprintf("%s %s -> %d", method, path, code) - returnTo := strings.TrimSpace(r.FormValue("return_to")) - if returnTo == "control" || returnTo == "config" { - data := u.deviceDetailPageData(dev) - data.Message = msg - data.RawText = string(body) - data.ResultTitle = "执行结果摘要" - if err != nil { - data.Error = err.Error() - } - u.render(w, r, "device", data) - return - } - data := PageData{Title: "设备详情", Device: dev, Message: msg, RawText: string(body)} - if err != nil { - data.Error = err.Error() - } - u.render(w, r, "device", data) + body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil) + msg := fmt.Sprintf("%s %s -> %d", method, path, code) + returnTo := strings.TrimSpace(r.FormValue("return_to")) + if returnTo == "control" || returnTo == "config" { + data := u.deviceDetailPageData(dev) + data.Message = msg + data.RawText = string(body) + data.ResultTitle = "执行结果摘要" + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device", data) + return + } + data := PageData{Title: "设备详情", Device: dev, Message: msg, RawText: string(body)} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device", data) } func (u *UI) pageDeviceLogs(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - dev, ok := u.findDevice(id) - if !ok { - http.NotFound(w, r) - return - } - limit := strings.TrimSpace(r.URL.Query().Get("limit")) - path := "/v1/logs/recent" - if limit != "" { - path += "?limit=" + urlQueryEscape(limit) - } + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + limit := strings.TrimSpace(r.URL.Query().Get("limit")) + path := "/v1/logs/recent" + if limit != "" { + path += "?limit=" + urlQueryEscape(limit) + } - body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, path, nil) - data := PageData{Title: "诊断日志", Device: dev, Message: fmt.Sprintf("GET %s -> %d", path, code), RawText: string(body)} - if err != nil { - data.Error = err.Error() - } - u.render(w, r, "device_logs", data) + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, path, nil) + data := PageData{Title: "诊断日志", Device: dev, Message: fmt.Sprintf("GET %s -> %d", path, code), RawText: string(body)} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device_logs", data) } func (u *UI) pageDeviceGraphs(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - dev, ok := u.findDevice(id) - if !ok { - http.NotFound(w, r) - return - } - body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil) - data := PageData{Title: "运行指标", Device: dev, Message: fmt.Sprintf("GET /v1/graphs -> %d", code), RawText: string(body)} - if err != nil { - data.Error = err.Error() - } - u.render(w, r, "device_graphs", data) + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil) + data := PageData{Title: "运行指标", Device: dev, Message: fmt.Sprintf("GET /v1/graphs -> %d", code), RawText: string(body)} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device_graphs", data) } func (u *UI) actionDeviceConfigApply(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 = `{"config":{}}` - } + 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 = `{"config":{}}` + } - body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", []byte(raw)) - data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw} - if err != nil { - data.Error = err.Error() - } - u.render(w, r, "device", data) + body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", []byte(raw)) + data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw} + if err != nil { + data.Error = err.Error() + } + u.render(w, r, "device", data) } func (u *UI) actionDeviceAliasSave(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() - alias := strings.TrimSpace(r.FormValue("device_alias")) - data := u.deviceDetailPageData(dev) - if err := u.registry.SetDeviceAlias(id, alias); err != nil { - data.Error = err.Error() - u.render(w, r, "device", data) - return - } - dev.DeviceAlias = alias - data.Device = dev - data.Message = "设备别名已保存" - u.render(w, r, "device", data) + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + alias := strings.TrimSpace(r.FormValue("device_alias")) + data := u.deviceDetailPageData(dev) + if err := u.registry.SetDeviceAlias(id, alias); err != nil { + data.Error = err.Error() + u.render(w, r, "device", data) + return + } + dev.DeviceAlias = alias + data.Device = dev + data.Message = "设备别名已保存" + u.render(w, r, "device", data) } func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - dev, ok := u.findDevice(id) - if !ok { - http.NotFound(w, r) - return - } + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } - if err := r.ParseMultipartForm(100 << 20); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - name := strings.TrimSpace(r.FormValue("name")) - file, hdr, err := r.FormFile("file") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - defer file.Close() - if name == "" { - http.Error(w, "name is required", http.StatusBadRequest) - return - } - path := fmt.Sprintf("/v1/models/%s", name) - resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size) - out := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} - if derr != nil { - out.Error = derr.Error() - } - u.render(w, r, "device", out) + if err := r.ParseMultipartForm(100 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + file, hdr, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + if name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + path := fmt.Sprintf("/v1/models/%s", name) + resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size) + out := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} + if derr != nil { + out.Error = derr.Error() + } + u.render(w, r, "device", out) } func (u *UI) actionDeviceMediaServerConfigUpload(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - dev, ok := u.findDevice(id) - if !ok { - http.NotFound(w, r) - return - } + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } - if err := r.ParseMultipartForm(50 << 20); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - name, err := normalizeConfigName(r.FormValue("name")) - if 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() + if err := r.ParseMultipartForm(50 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name, err := normalizeConfigName(r.FormValue("name")) + if 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() - path := "/v1/media-server/configs/" + url.PathEscape(name) - resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) - data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} - if derr != nil { - data.Error = derr.Error() - } - u.render(w, r, "device", data) + path := "/v1/media-server/configs/" + url.PathEscape(name) + resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) + data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} + if derr != nil { + data.Error = derr.Error() + } + u.render(w, r, "device", data) } func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - dev, ok := u.findDevice(id) - if !ok { - http.NotFound(w, r) - return - } + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } - if err := r.ParseMultipartForm(200 << 20); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 { - http.Error(w, "files is required", http.StatusBadRequest) - return - } - files := r.MultipartForm.File["files"] - if len(files) == 0 { - http.Error(w, "files is required", http.StatusBadRequest) - return - } + if err := r.ParseMultipartForm(200 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 { + http.Error(w, "files is required", http.StatusBadRequest) + return + } + files := r.MultipartForm.File["files"] + if len(files) == 0 { + http.Error(w, "files is required", http.StatusBadRequest) + return + } - var sb strings.Builder - errCount := 0 - for _, hdr := range files { - name, nerr := normalizeConfigName(filepath.Base(hdr.Filename)) - if nerr != nil { - errCount++ - sb.WriteString(fmt.Sprintf("%s -> invalid name: %v\n", hdr.Filename, nerr)) - continue - } - file, err := hdr.Open() - if err != nil { - errCount++ - sb.WriteString(fmt.Sprintf("%s -> open failed: %v\n", name, err)) - continue - } - path := "/v1/media-server/configs/" + url.PathEscape(name) - resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) - _ = file.Close() - if derr != nil { - errCount++ - sb.WriteString(fmt.Sprintf("%s -> %d error: %v\n", name, code, derr)) - continue - } - if len(resp) > 0 { - sb.WriteString(fmt.Sprintf("%s -> %d %s\n", name, code, strings.TrimSpace(string(resp)))) - } else { - sb.WriteString(fmt.Sprintf("%s -> %d\n", name, code)) - } - } + var sb strings.Builder + errCount := 0 + for _, hdr := range files { + name, nerr := normalizeConfigName(filepath.Base(hdr.Filename)) + if nerr != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> invalid name: %v\n", hdr.Filename, nerr)) + continue + } + file, err := hdr.Open() + if err != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> open failed: %v\n", name, err)) + continue + } + path := "/v1/media-server/configs/" + url.PathEscape(name) + resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) + _ = file.Close() + if derr != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> %d error: %v\n", name, code, derr)) + continue + } + if len(resp) > 0 { + sb.WriteString(fmt.Sprintf("%s -> %d %s\n", name, code, strings.TrimSpace(string(resp)))) + } else { + sb.WriteString(fmt.Sprintf("%s -> %d\n", name, code)) + } + } - data := PageData{Title: "设备详情", Device: dev, Message: "批量上传完成", RawText: sb.String()} - if errCount > 0 { - data.Error = fmt.Sprintf("部分失败: %d/%d", errCount, len(files)) - } - u.render(w, r, "device", data) + data := PageData{Title: "设备详情", Device: dev, Message: "批量上传完成", RawText: sb.String()} + if errCount > 0 { + data.Error = fmt.Sprintf("部分失败: %d/%d", errCount, len(files)) + } + u.render(w, r, "device", data) } func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) { - u.ensureDevicesLoaded() - devices := u.registry.GetDevices() - selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"])) - data := PageData{ - Title: "任务中心", - Tasks: u.tasks.ListTasks(), - Devices: devices, - SelectedDeviceIDs: selectedIDs, - SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs), - DeviceIDs: strings.Join(selectedIDs, ","), - } - u.render(w, r, "tasks", data) + u.ensureDevicesLoaded() + devices := u.registry.GetDevices() + selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"])) + data := PageData{ + Title: "任务中心", + Tasks: u.tasks.ListTasks(), + Devices: devices, + SelectedDeviceIDs: selectedIDs, + SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs), + DeviceIDs: strings.Join(selectedIDs, ","), + } + u.render(w, r, "tasks", data) } func (u *UI) taskPageData(task *models.Task) PageData { - data := PageData{Title: "任务中心", Task: task} - if task == nil { - return data - } + data := PageData{Title: "任务中心", Task: task} + if task == nil { + return data + } - devices := make(map[string]*models.Device) - if u.registry != nil { - for _, dev := range u.registry.GetDevices() { - if dev == nil { - continue - } - devices[dev.DeviceID] = dev - } - } + devices := make(map[string]*models.Device) + if u.registry != nil { + for _, dev := range u.registry.GetDevices() { + if dev == nil { + continue + } + devices[dev.DeviceID] = dev + } + } - rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs)) - for _, did := range task.DeviceIDs { - row := TaskDeviceRow{} - if dev := devices[did]; dev != nil { - row.Device = dev - } else { - row.Device = &models.Device{DeviceID: did} - } - if ds := task.Devices[did]; ds != nil { - row.Status = ds.Status - row.Progress = ds.Progress - row.Error = ds.Error - } - rows = append(rows, row) - } - data.TaskDeviceRows = rows - return data + rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs)) + for _, did := range task.DeviceIDs { + row := TaskDeviceRow{} + if dev := devices[did]; dev != nil { + row.Device = dev + } else { + row.Device = &models.Device{DeviceID: did} + } + if ds := task.Devices[did]; ds != nil { + row.Status = ds.Status + row.Progress = ds.Progress + row.Error = ds.Error + } + rows = append(rows, row) + } + data.TaskDeviceRows = rows + return data } func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - typeStr := strings.TrimSpace(r.FormValue("type")) - if typeStr == "" { - typeStr = "config_apply" - } - ids := strings.TrimSpace(r.FormValue("device_ids")) - var deviceIDs []string - if ids != "" { - for _, p := range strings.Split(ids, ",") { - p = strings.TrimSpace(p) - if p != "" { - deviceIDs = append(deviceIDs, p) - } - } - } else { - deviceIDs = filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) - ids = strings.Join(deviceIDs, ",") - } - raw := strings.TrimSpace(r.FormValue("payload_json")) - if raw == "" { - raw = `{"config":{}}` - } - var payload any - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids}) - return - } + _ = r.ParseForm() + typeStr := strings.TrimSpace(r.FormValue("type")) + if typeStr == "" { + typeStr = "config_apply" + } + ids := strings.TrimSpace(r.FormValue("device_ids")) + var deviceIDs []string + if ids != "" { + for _, p := range strings.Split(ids, ",") { + p = strings.TrimSpace(p) + if p != "" { + deviceIDs = append(deviceIDs, p) + } + } + } else { + deviceIDs = filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) + ids = strings.Join(deviceIDs, ",") + } + raw := strings.TrimSpace(r.FormValue("payload_json")) + if raw == "" { + raw = `{"config":{}}` + } + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids}) + return + } - task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) - if err != nil { - u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids}) - return - } - http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) + task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) + if err != nil { + u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids}) + return + } + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) } func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - items := u.tasks.ListTasks() - var task *models.Task - for i := range items { - if items[i].ID == id { - t := items[i] - task = &t - break - } - } - if task == nil { - http.NotFound(w, r) - return - } - data := u.taskPageData(task) - data.TaskID = id - u.render(w, r, "task", data) + id := chi.URLParam(r, "id") + items := u.tasks.ListTasks() + var task *models.Task + for i := range items { + if items[i].ID == id { + t := items[i] + task = &t + break + } + } + if task == nil { + http.NotFound(w, r) + return + } + data := u.taskPageData(task) + data.TaskID = id + u.render(w, r, "task", data) } func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/assets/templates", http.StatusFound) + http.Redirect(w, r, "/ui/assets/templates", http.StatusFound) } func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) + http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) } func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) { - u.ensureDevicesLoaded() - data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()} - for _, dev := range data.Devices { - if dev == nil { - continue - } - if dev.Online { - data.OnlineCount++ - } - } - board := service.ModelStatusBoard{} - if strings.TrimSpace(u.dbPath) != "" { - if store, err := storage.OpenSQLite(u.dbPath); err == nil { - modelsRepo := storage.NewModelsRepo(store.DB()) - if items, err := modelsRepo.List(); err == nil { - data.StandardModels = items - installed := map[string][]service.InstalledModelStatus{} - for _, device := range data.Devices { - if device == nil || !device.Online { - continue - } - items, err := service.FetchInstalledModelStatuses(u.agent, device) - if err == nil { - installed[device.DeviceID] = items - } - } - board = service.BuildModelStatusBoard(items, data.Devices, installed) - } - _ = store.Close() - } - } - data.ModelStatusBoard = &board - u.render(w, r, "models", data) + u.ensureDevicesLoaded() + data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()} + for _, dev := range data.Devices { + if dev == nil { + continue + } + if dev.Online { + data.OnlineCount++ + } + } + board := service.ModelStatusBoard{} + if strings.TrimSpace(u.dbPath) != "" { + if store, err := storage.OpenSQLite(u.dbPath); err == nil { + modelsRepo := storage.NewModelsRepo(store.DB()) + if items, err := modelsRepo.List(); err == nil { + data.StandardModels = items + installed := map[string][]service.InstalledModelStatus{} + for _, device := range data.Devices { + if device == nil || !device.Online { + continue + } + items, err := service.FetchInstalledModelStatuses(u.agent, device) + if err == nil { + installed[device.DeviceID] = items + } + } + board = service.BuildModelStatusBoard(items, data.Devices, installed) + } + _ = store.Close() + } + } + data.ModelStatusBoard = &board + u.render(w, r, "models", data) } func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - action := strings.TrimSpace(r.FormValue("action")) - if action == "" { - action = "model_sync_all" - } - if action != "model_sync_one" && action != "model_sync_all" { - http.Error(w, "invalid action", http.StatusBadRequest) - return - } - deviceIDs := make([]string, 0) - for _, id := range r.Form["device_id"] { - id = strings.TrimSpace(id) - if id != "" { - deviceIDs = append(deviceIDs, id) - } - } - if len(deviceIDs) == 0 { - http.Error(w, "missing device_id", http.StatusBadRequest) - return - } - payload := map[string]any{} - if modelName := strings.TrimSpace(r.FormValue("model_name")); modelName != "" { - payload["model_name"] = modelName - } - task, err := u.tasks.CreateTask(action, deviceIDs, payload) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + action := strings.TrimSpace(r.FormValue("action")) + if action == "" { + action = "model_sync_all" + } + if action != "model_sync_one" && action != "model_sync_all" { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + deviceIDs := make([]string, 0) + for _, id := range r.Form["device_id"] { + id = strings.TrimSpace(id) + if id != "" { + deviceIDs = append(deviceIDs, id) + } + } + if len(deviceIDs) == 0 { + http.Error(w, "missing device_id", http.StatusBadRequest) + return + } + payload := map[string]any{} + if modelName := strings.TrimSpace(r.FormValue("model_name")); modelName != "" { + payload["model_name"] = modelName + } + task, err := u.tasks.CreateTask(action, deviceIDs, payload) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) } func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) { - u.ensureDevicesLoaded() - data := PageData{Title: "日志审计", Devices: u.registry.GetDevices()} - 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, "diagnostics", data) + u.ensureDevicesLoaded() + data := PageData{Title: "日志审计", Devices: u.registry.GetDevices()} + 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, "diagnostics", data) } func (u *UI) pageAlarms(w http.ResponseWriter, r *http.Request) { - u.ensureDevicesLoaded() - data := PageData{Title: "告警中心", Devices: u.registry.GetDevices()} - if u.alarmCollector != nil { - data.AlarmRecords = u.alarmCollector.GetRecent(100) - } - u.render(w, r, "alarms", data) + u.ensureDevicesLoaded() + data := PageData{Title: "告警中心", Devices: u.registry.GetDevices()} + if u.alarmCollector != nil { + data.AlarmRecords = u.alarmCollector.GetRecent(100) + } + u.render(w, r, "alarms", data) } func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) { - u.ensureDevicesLoaded() - data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()} - for _, dev := range data.Devices { - if dev == nil { - continue - } - if dev.Online { - data.OnlineCount++ - } - } - board := service.ResourceStatusBoard{} - if strings.TrimSpace(u.dbPath) != "" { - if store, err := storage.OpenSQLite(u.dbPath); err == nil { - resourcesRepo := storage.NewResourcesRepo(store.DB()) - if items, err := resourcesRepo.List(); err == nil { - data.StandardResources = items - board = service.FetchAndBuildResourceBoard(u.agent, data.Devices, items) - } - _ = store.Close() - } - } - data.ResourceStatusBoard = &board - u.render(w, r, "resources", data) + u.ensureDevicesLoaded() + data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()} + for _, dev := range data.Devices { + if dev == nil { + continue + } + if dev.Online { + data.OnlineCount++ + } + } + board := service.ResourceStatusBoard{} + if strings.TrimSpace(u.dbPath) != "" { + if store, err := storage.OpenSQLite(u.dbPath); err == nil { + resourcesRepo := storage.NewResourcesRepo(store.DB()) + if items, err := resourcesRepo.List(); err == nil { + data.StandardResources = items + board = service.FetchAndBuildResourceBoard(u.agent, data.Devices, items) + } + _ = store.Close() + } + } + data.ResourceStatusBoard = &board + u.render(w, r, "resources", data) } func (u *UI) actionResourceSync(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - action := strings.TrimSpace(r.FormValue("action")) - if action == "" { - action = "resource_sync_all" - } - if action != "resource_sync_one" && action != "resource_sync_all" { - http.Error(w, "invalid action", http.StatusBadRequest) - return - } - deviceIDs := make([]string, 0) - for _, id := range r.Form["device_id"] { - id = strings.TrimSpace(id) - if id != "" { - deviceIDs = append(deviceIDs, id) - } - } - if len(deviceIDs) == 0 { - http.Error(w, "missing device_id", http.StatusBadRequest) - return - } - payload := map[string]any{} - if resourceName := strings.TrimSpace(r.FormValue("resource_name")); resourceName != "" { - payload["resource_name"] = resourceName - } - task, err := u.tasks.CreateTask(action, deviceIDs, payload) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + action := strings.TrimSpace(r.FormValue("action")) + if action == "" { + action = "resource_sync_all" + } + if action != "resource_sync_one" && action != "resource_sync_all" { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + deviceIDs := make([]string, 0) + for _, id := range r.Form["device_id"] { + id = strings.TrimSpace(id) + if id != "" { + deviceIDs = append(deviceIDs, id) + } + } + if len(deviceIDs) == 0 { + http.Error(w, "missing device_id", http.StatusBadRequest) + return + } + payload := map[string]any{} + if resourceName := strings.TrimSpace(r.FormValue("resource_name")); resourceName != "" { + payload["resource_name"] = resourceName + } + task, err := u.tasks.CreateTask(action, deviceIDs, payload) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound) } func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/assets", http.StatusFound) + http.Redirect(w, r, "/ui/assets", http.StatusFound) } func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/diagnostics", http.StatusFound) + http.Redirect(w, r, "/ui/diagnostics", http.StatusFound) } func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) { - u.render(w, r, "api", PageData{Title: "高级调试"}) + u.render(w, r, "api", PageData{Title: "高级调试"}) } func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) { - data := u.assetPageData("overview") - u.render(w, r, "assets", data) + data := u.assetPageData("overview") + u.render(w, r, "assets", data) } func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { - data := u.assetPageData("templates") - data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) - if data.Error == "" { - data.Error = strings.TrimSpace(r.URL.Query().Get("error")) - } - editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" - if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { - if item, err := u.preview.GetTemplateAsset(name); err == nil { - data.AssetTemplate = item - } else if data.Error == "" { - data.Error = err.Error() - } - } else if len(data.AssetTemplates) > 0 { - if item, err := u.preview.GetTemplateAsset(data.AssetTemplates[0].Name); err == nil { - data.AssetTemplate = item - } else if data.Error == "" { - data.Error = err.Error() - } - } - data.AssetTemplateEditing = editMode && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly - u.render(w, r, "asset_templates", data) + data := u.assetPageData("templates") + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { + if item, err := u.preview.GetTemplateAsset(name); err == nil { + data.AssetTemplate = item + } else if data.Error == "" { + data.Error = err.Error() + } + } else if len(data.AssetTemplates) > 0 { + if item, err := u.preview.GetTemplateAsset(data.AssetTemplates[0].Name); err == nil { + data.AssetTemplate = item + } else if data.Error == "" { + data.Error = err.Error() + } + } + data.AssetTemplateEditing = editMode && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly + u.render(w, r, "asset_templates", data) } func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) { - if u.preview == nil { - http.Error(w, "config preview service is not configured", http.StatusInternalServerError) - return - } - name, err := normalizeConfigName(r.FormValue("name")) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - name = strings.TrimSuffix(name, ".json") - description := strings.TrimSpace(r.FormValue("description")) - cloneSource := strings.TrimSpace(r.FormValue("clone_source")) + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + name, err := normalizeConfigName(r.FormValue("name")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name = strings.TrimSuffix(name, ".json") + description := strings.TrimSpace(r.FormValue("description")) + cloneSource := strings.TrimSpace(r.FormValue("clone_source")) - var doc map[string]any - if cloneSource != "" { - item, err := u.preview.GetTemplateAsset(cloneSource) - if err != nil || item == nil { - http.Error(w, "clone source template not found", http.StatusNotFound) - return - } - body, err := json.Marshal(item.Raw) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := json.Unmarshal(body, &doc); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } else { - doc = map[string]any{ - "name": name, - "description": description, - "params": map[string]any{}, - "template": map[string]any{ - "nodes": []any{}, - "edges": []any{}, - }, - } - } + var doc map[string]any + if cloneSource != "" { + item, err := u.preview.GetTemplateAsset(cloneSource) + if err != nil || item == nil { + http.Error(w, "clone source template not found", http.StatusNotFound) + return + } + body, err := json.Marshal(item.Raw) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := json.Unmarshal(body, &doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + doc = map[string]any{ + "name": name, + "description": description, + "params": map[string]any{}, + "template": map[string]any{ + "nodes": []any{}, + "edges": []any{}, + }, + } + } - doc["name"] = name - doc["description"] = description - body, err := json.MarshalIndent(doc, "", " ") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := u.preview.SaveTemplateAsset(name, description, string(body)+"\n"); err != nil { - status := http.StatusInternalServerError - if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") { - status = http.StatusBadRequest - } - http.Error(w, err.Error(), status) - return - } - http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(name)+"/graph?msg="+urlQueryEscape("模板已创建"), http.StatusFound) + doc["name"] = name + doc["description"] = description + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := u.preview.SaveTemplateAsset(name, description, string(body)+"\n"); err != nil { + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") { + status = http.StatusBadRequest + } + http.Error(w, err.Error(), status) + return + } + http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(name)+"/graph?msg="+urlQueryEscape("模板已创建"), http.StatusFound) } func (u *UI) actionAssetTemplateClone(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.GetTemplateAsset(sourceName) - if err != nil || item == nil { - http.NotFound(w, r) - return - } - if !item.ReadOnly { - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound) - return - } - targetName, err := u.nextTemplateCloneName(item.Name) - if err != nil { - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) - return - } - body, err := json.Marshal(item.Raw) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - doc := map[string]any{} - if err := json.Unmarshal(body, &doc); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - doc["name"] = targetName - doc["description"] = item.Description - pretty, err := json.MarshalIndent(doc, "", " ") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := u.preview.SaveTemplateAsset(targetName, item.Description, string(pretty)+"\n"); err != nil { - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&edit=1&msg="+urlQueryEscape("标准模板已复制,请继续编辑"), http.StatusFound) + 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.GetTemplateAsset(sourceName) + if err != nil || item == nil { + http.NotFound(w, r) + return + } + if !item.ReadOnly { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound) + return + } + targetName, err := u.nextTemplateCloneName(item.Name) + if err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + body, err := json.Marshal(item.Raw) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc := map[string]any{} + if err := json.Unmarshal(body, &doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc["name"] = targetName + doc["description"] = item.Description + pretty, err := json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := u.preview.SaveTemplateAsset(targetName, item.Description, string(pretty)+"\n"); err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&edit=1&msg="+urlQueryEscape("标准模板已复制,请继续编辑"), http.StatusFound) } func (u *UI) nextTemplateCloneName(sourceName string) (string, error) { - base := strings.TrimSpace(sourceName) - if base == "" { - return "", fmt.Errorf("template name is required") - } - if strings.HasPrefix(base, "std_") { - base = strings.TrimPrefix(base, "std_") - } - candidate := base + "_copy" - if item, err := u.preview.GetTemplateAsset(candidate); err == nil && item != nil { - for i := 2; i < 1000; i++ { - name := fmt.Sprintf("%s_copy_%d", base, i) - item, err := u.preview.GetTemplateAsset(name) - if err != nil || item == nil { - return name, nil - } - } - return "", fmt.Errorf("无法生成可用的模板副本名称") - } - return candidate, nil + base := strings.TrimSpace(sourceName) + if base == "" { + return "", fmt.Errorf("template name is required") + } + if strings.HasPrefix(base, "std_") { + base = strings.TrimPrefix(base, "std_") + } + candidate := base + "_copy" + if item, err := u.preview.GetTemplateAsset(candidate); err == nil && item != nil { + for i := 2; i < 1000; i++ { + name := fmt.Sprintf("%s_copy_%d", base, i) + item, err := u.preview.GetTemplateAsset(name) + if err != nil || item == nil { + return name, nil + } + } + return "", fmt.Errorf("无法生成可用的模板副本名称") + } + return candidate, nil } func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - data := u.assetPageData("templates") - data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) - if data.Error == "" { - data.Error = strings.TrimSpace(r.URL.Query().Get("error")) - } - item, err := u.preview.GetTemplateAsset(name) - if err != nil { - http.NotFound(w, r) - return - } - data.AssetTemplate = item - u.render(w, r, "asset_templates", data) + name := chi.URLParam(r, "name") + data := u.assetPageData("templates") + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + item, err := u.preview.GetTemplateAsset(name) + if err != nil { + http.NotFound(w, r) + return + } + data.AssetTemplate = item + u.render(w, r, "asset_templates", data) } func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - data := u.assetPageData("templates") - data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) - if data.Error == "" { - data.Error = strings.TrimSpace(r.URL.Query().Get("error")) - } - if u.preview == nil { - data.Error = "配置中心服务未初始化" - u.render(w, r, "asset_templates", data) - return - } - item, err := u.preview.GetTemplateAsset(name) - if err != nil { - http.NotFound(w, r) - return - } - data.Title = "模板可视化编辑" - if item.ReadOnly { - data.Title = "标准模板可视化预览" - } - data.AssetTemplate = item - raw, err := compactJSON(item.Raw) - if err != nil { - data.Error = err.Error() - u.render(w, r, "asset_templates", data) - return - } - data.RawJSON = raw - u.render(w, r, "asset_template_graph", data) + name := chi.URLParam(r, "name") + data := u.assetPageData("templates") + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + if u.preview == nil { + data.Error = "配置中心服务未初始化" + u.render(w, r, "asset_templates", data) + return + } + item, err := u.preview.GetTemplateAsset(name) + if err != nil { + http.NotFound(w, r) + return + } + data.Title = "模板可视化编辑" + if item.ReadOnly { + data.Title = "标准模板可视化预览" + } + data.AssetTemplate = item + raw, err := compactJSON(item.Raw) + if err != nil { + data.Error = err.Error() + u.render(w, r, "asset_templates", data) + return + } + data.RawJSON = raw + u.render(w, r, "asset_template_graph", data) } func (u *UI) actionAssetTemplateGraphSave(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - if u.preview == nil { - http.Error(w, "config preview service is not configured", http.StatusInternalServerError) - return - } - raw := strings.TrimSpace(r.FormValue("json")) - if raw == "" { - http.Error(w, "template json is required", http.StatusBadRequest) - return - } - var doc map[string]any - if err := json.Unmarshal([]byte(raw), &doc); err != nil { - http.Error(w, "invalid template json: "+err.Error(), http.StatusBadRequest) - return - } - if err := validateTemplateGraphDocument(doc); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - targetName := name - if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" { - normalized, err := normalizeConfigName(rawName) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - targetName = strings.TrimSuffix(normalized, ".json") - } - description := strings.TrimSpace(r.FormValue("description")) - if description == "" { - description, _ = doc["description"].(string) - } - doc["name"] = targetName - doc["description"] = description - body, err := json.MarshalIndent(doc, "", " ") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if targetName != name { - err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") - } else { - err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") - } - if err != nil { - status := http.StatusInternalServerError - if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") { - status = http.StatusBadRequest - } - http.Error(w, err.Error(), status) - return - } - message := "模板已保存" - if targetName != name { - message = "模板已保存,名称已更新" - } - http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(targetName)+"/graph?msg="+urlQueryEscape(message), http.StatusFound) + name := chi.URLParam(r, "name") + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + raw := strings.TrimSpace(r.FormValue("json")) + if raw == "" { + http.Error(w, "template json is required", http.StatusBadRequest) + return + } + var doc map[string]any + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + http.Error(w, "invalid template json: "+err.Error(), http.StatusBadRequest) + return + } + if err := validateTemplateGraphDocument(doc); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + targetName := name + if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" { + normalized, err := normalizeConfigName(rawName) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + targetName = strings.TrimSuffix(normalized, ".json") + } + description := strings.TrimSpace(r.FormValue("description")) + if description == "" { + description, _ = doc["description"].(string) + } + doc["name"] = targetName + doc["description"] = description + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if targetName != name { + err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") + } else { + err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") + } + if err != nil { + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") { + status = http.StatusBadRequest + } + http.Error(w, err.Error(), status) + return + } + message := "模板已保存" + if targetName != name { + message = "模板已保存,名称已更新" + } + http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(targetName)+"/graph?msg="+urlQueryEscape(message), http.StatusFound) } func (u *UI) actionAssetTemplateRename(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - if u.preview == nil { - http.Error(w, "config preview service is not configured", http.StatusInternalServerError) - return - } - item, err := u.preview.GetTemplateAsset(name) - if err != nil || item == nil { - http.NotFound(w, r) - return - } - targetName := name - if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" { - normalized, err := normalizeConfigName(rawName) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - targetName = strings.TrimSuffix(normalized, ".json") - } - description := strings.TrimSpace(r.FormValue("description")) - if description == "" { - description = item.Description - } - body, err := json.Marshal(item.Raw) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - doc := map[string]any{} - if err := json.Unmarshal(body, &doc); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - doc["name"] = targetName - doc["description"] = description - body, err = json.MarshalIndent(doc, "", " ") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if targetName != name { - err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") - } else { - err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") - } - if err != nil { - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&msg="+urlQueryEscape("模板已保存,名称已更新"), http.StatusFound) + name := chi.URLParam(r, "name") + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + item, err := u.preview.GetTemplateAsset(name) + if err != nil || item == nil { + http.NotFound(w, r) + return + } + targetName := name + if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" { + normalized, err := normalizeConfigName(rawName) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + targetName = strings.TrimSuffix(normalized, ".json") + } + description := strings.TrimSpace(r.FormValue("description")) + if description == "" { + description = item.Description + } + body, err := json.Marshal(item.Raw) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc := map[string]any{} + if err := json.Unmarshal(body, &doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc["name"] = targetName + doc["description"] = description + body, err = json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if targetName != name { + err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") + } else { + err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") + } + if err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&msg="+urlQueryEscape("模板已保存,名称已更新"), http.StatusFound) } func (u *UI) actionAssetTemplateDelete(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - if u.preview == nil { - http.Error(w, "config preview service is not configured", http.StatusInternalServerError) - return - } - if err := u.preview.DeleteTemplateAsset(name); err != nil { - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/assets/templates?msg="+urlQueryEscape("用户模板已删除"), http.StatusFound) + name := chi.URLParam(r, "name") + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + if err := u.preview.DeleteTemplateAsset(name); err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/templates?msg="+urlQueryEscape("用户模板已删除"), http.StatusFound) } func (u *UI) apiGraphNodeTypes(w http.ResponseWriter, r *http.Request) { - if body, ok := u.loadAgentGraphNodeTypes(); ok { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - _, _ = w.Write(body) - return - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(map[string]any{"items": graphNodeTypesCatalog()}) + if body, ok := u.loadAgentGraphNodeTypes(); ok { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write(body) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"items": graphNodeTypesCatalog()}) } func (u *UI) loadAgentGraphNodeTypes() ([]byte, bool) { - if u.agent == nil || u.registry == nil { - return nil, false - } - u.ensureDevicesLoaded() - for _, dev := range u.registry.GetDevices() { - if dev == nil || strings.TrimSpace(dev.IP) == "" || dev.AgentPort <= 0 { - continue - } - body, code, err := u.agent.Do(http.MethodGet, dev.IP, dev.AgentPort, "/v1/graph-node-types", nil) - if err != nil || code < 200 || code >= 300 { - continue - } - var payload struct { - Items []graphNodeTypeInfo `json:"items"` - } - if err := json.Unmarshal(body, &payload); err != nil || len(payload.Items) == 0 { - continue - } - return body, true - } - return nil, false + if u.agent == nil || u.registry == nil { + return nil, false + } + u.ensureDevicesLoaded() + for _, dev := range u.registry.GetDevices() { + if dev == nil || strings.TrimSpace(dev.IP) == "" || dev.AgentPort <= 0 { + continue + } + body, code, err := u.agent.Do(http.MethodGet, dev.IP, dev.AgentPort, "/v1/graph-node-types", nil) + if err != nil || code < 200 || code >= 300 { + continue + } + var payload struct { + Items []graphNodeTypeInfo `json:"items"` + } + if err := json.Unmarshal(body, &payload); err != nil || len(payload.Items) == 0 { + continue + } + return body, true + } + return nil, false } func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) { - u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name")) + u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name")) } func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { - data := u.assetPageData("") - 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 == "" && !newMode && len(data.AssetProfiles) > 0 { - selected = data.AssetProfiles[0].Name - } - if newMode { - editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} - if len(data.AssetTemplates) > 0 { - editor.PrimaryTemplateName = data.AssetTemplates[0].Name - } - data.AssetProfileEditor = editor - data.AssetProfileEditing = true - data.AssetProfileFormAction = "/ui/scene-templates/create" - data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) - u.render(w, r, "scene_templates", data) - return - } - if selected != "" { - editor, err := u.preview.GetProfileEditor(selected) - if err == nil { - data.AssetProfileEditor = editor - data.SelectedProfile = editor.Name - data.AssetProfileEditing = editMode - data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(editor.Name) - if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { - data.SelectedTemplate = editor.Instances[0].Template - } - data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query())) - } else if data.Error == "" { - data.Error = err.Error() - } - } else if len(data.AssetProfiles) == 0 { - editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} - if len(data.AssetTemplates) > 0 { - editor.PrimaryTemplateName = data.AssetTemplates[0].Name - } - data.AssetProfileEditor = editor - data.AssetProfileEditing = true - data.AssetProfileFormAction = "/ui/scene-templates/create" - data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) - } - u.render(w, r, "scene_templates", data) + data := u.assetPageData("") + 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 == "" && !newMode && len(data.AssetProfiles) > 0 { + selected = data.AssetProfiles[0].Name + } + if newMode { + editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} + if len(data.AssetTemplates) > 0 { + editor.PrimaryTemplateName = data.AssetTemplates[0].Name + } + data.AssetProfileEditor = editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) + u.render(w, r, "scene_templates", data) + return + } + if selected != "" { + editor, err := u.preview.GetProfileEditor(selected) + if err == nil { + data.AssetProfileEditor = editor + data.SelectedProfile = editor.Name + data.AssetProfileEditing = editMode + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(editor.Name) + if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { + data.SelectedTemplate = editor.Instances[0].Template + } + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query())) + } else if data.Error == "" { + data.Error = err.Error() + } + } else if len(data.AssetProfiles) == 0 { + editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} + if len(data.AssetTemplates) > 0 { + editor.PrimaryTemplateName = data.AssetTemplates[0].Name + } + data.AssetProfileEditor = editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) + } + u.render(w, r, "scene_templates", data) } func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) { - u.pagePlans(w, r) + u.pagePlans(w, r) } func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - data, err := u.profileEditorPageData(name) - if err != nil { - http.NotFound(w, r) - return - } - data.Title = "场景" - data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1" - data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name) - data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query())) - u.render(w, r, "scene_templates", data) + name := chi.URLParam(r, "name") + data, err := u.profileEditorPageData(name) + if err != nil { + http.NotFound(w, r) + return + } + data.Title = "场景" + data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name) + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query())) + u.render(w, r, "scene_templates", data) } func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) { - u.pagePlan(w, r) + u.pagePlan(w, r) } func (u *UI) pagePlanExport(w http.ResponseWriter, r *http.Request) { - u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name")) + u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name")) } func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) { - u.pagePlanExport(w, r) + u.pagePlanExport(w, r) } func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) { - u.actionPlanSaveWithName(w, r, chi.URLParam(r, "name")) + u.actionPlanSaveWithName(w, r, chi.URLParam(r, "name")) } func (u *UI) actionPlanCreate(w http.ResponseWriter, r *http.Request) { - u.actionPlanSaveWithName(w, r, "") + u.actionPlanSaveWithName(w, r, "") } func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name string) { - var ( - editor service.ConfigProfileEditor - data PageData - err error - ) - if strings.TrimSpace(name) == "" { - data = u.assetPageData("") - data.Title = "场景" - _ = 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), - } - data.AssetProfileEditor = &editor - data.AssetProfileEditing = true - data.AssetProfileFormAction = "/ui/scene-templates/create" - data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) - } else { - editor, data, err = u.profileEditorActionData(r, name) - if err != nil { - http.NotFound(w, r) - return - } - data.AssetProfileEditing = true - data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name) - } - if err := u.preview.SaveProfileEditor(editor); err != nil { - data.Error = err.Error() - data.Title = "场景" - data.AssetProfileEditing = true - u.render(w, r, "scene_templates", data) - return - } - msg := "场景已保存" - if strings.TrimSpace(name) != "" && editor.Name != name { - msg = "场景已保存,名称已更新" - } - http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound) + var ( + editor service.ConfigProfileEditor + data PageData + err error + ) + if strings.TrimSpace(name) == "" { + data = u.assetPageData("") + data.Title = "场景" + _ = 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), + } + data.AssetProfileEditor = &editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) + } else { + editor, data, err = u.profileEditorActionData(r, name) + if err != nil { + http.NotFound(w, r) + return + } + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name) + } + if err := u.preview.SaveProfileEditor(editor); err != nil { + data.Error = err.Error() + data.Title = "场景" + data.AssetProfileEditing = true + u.render(w, r, "scene_templates", data) + return + } + msg := "场景已保存" + if strings.TrimSpace(name) != "" && editor.Name != name { + msg = "场景已保存,名称已更新" + } + http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound) } func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) { - name := strings.TrimSpace(chi.URLParam(r, "name")) - if name == "" { - http.NotFound(w, r) - return - } - if u.stateRepo != nil { - items, err := u.stateRepo.ListByProfileName(name) - if err != nil { - http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) - return - } - if len(items) > 0 { - deviceIDs := make([]string, 0, len(items)) - for _, item := range items { - deviceIDs = append(deviceIDs, item.DeviceID) - } - msg := fmt.Sprintf("场景 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、")) - http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound) - return - } - } - if err := u.preview.DeleteProfileAsset(name); err != nil { - http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound) + name := strings.TrimSpace(chi.URLParam(r, "name")) + if name == "" { + http.NotFound(w, r) + return + } + if u.stateRepo != nil { + items, err := u.stateRepo.ListByProfileName(name) + if err != nil { + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + if len(items) > 0 { + deviceIDs := make([]string, 0, len(items)) + for _, item := range items { + deviceIDs = append(deviceIDs, item.DeviceID) + } + msg := fmt.Sprintf("场景 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、")) + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound) + return + } + } + if err := u.preview.DeleteProfileAsset(name); err != nil { + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound) } func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { - u.actionPlanSave(w, r) + u.actionPlanSave(w, r) } func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request) { - target := "/ui/scene-templates" - if r.URL.RawQuery != "" { - target += "?" + r.URL.RawQuery - } - http.Redirect(w, r, target, http.StatusFound) + target := "/ui/scene-templates" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) } func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) + http.Redirect(w, r, "/ui/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) } func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Request) { - target := "/ui/scene-templates" - if r.URL.RawQuery != "" { - target += "?" + r.URL.RawQuery - } - http.Redirect(w, r, target, http.StatusFound) + target := "/ui/scene-templates" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) } func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request) { - target := "/ui/scene-templates/" + url.PathEscape(chi.URLParam(r, "name")) - if r.URL.RawQuery != "" { - target += "?" + r.URL.RawQuery - } - http.Redirect(w, r, target, http.StatusFound) + target := "/ui/scene-templates/" + url.PathEscape(chi.URLParam(r, "name")) + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) } func (u *UI) pageRecognitionUnits(w http.ResponseWriter, r *http.Request) { - data := u.assetPageData("") - 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("ref")) - if selected == "" && !newMode && len(data.RecognitionUnits) > 0 { - selected = data.RecognitionUnits[0].Ref - } - data.SelectedRecognitionUnit = selected - if newMode { - defaultTemplate := "" - if len(data.AssetProfiles) > 0 { - defaultTemplate = data.AssetProfiles[0].Name - } - data.RecognitionUnit = &service.RecognitionUnitAsset{ - SceneTemplateName: defaultTemplate, - OutputChannel: "cam1", - RTSPPort: "8555", - } - data.RecognitionUnitEditing = true - } else if selected != "" { - if item, err := u.preview.GetRecognitionUnit(selected); err == nil { - data.RecognitionUnit = item - data.RecognitionUnitEditing = editMode - } else if data.Error == "" { - data.Error = err.Error() - } - } - u.render(w, r, "recognition_units", data) + data := u.assetPageData("") + 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("ref")) + if selected == "" && !newMode && len(data.RecognitionUnits) > 0 { + selected = data.RecognitionUnits[0].Ref + } + data.SelectedRecognitionUnit = selected + if newMode { + defaultTemplate := "" + if len(data.AssetProfiles) > 0 { + defaultTemplate = data.AssetProfiles[0].Name + } + data.RecognitionUnit = &service.RecognitionUnitAsset{ + SceneTemplateName: defaultTemplate, + OutputChannel: "cam1", + RTSPPort: "8555", + } + data.RecognitionUnitEditing = true + } else if selected != "" { + if item, err := u.preview.GetRecognitionUnit(selected); err == nil { + data.RecognitionUnit = item + data.RecognitionUnitEditing = editMode + } else if data.Error == "" { + data.Error = err.Error() + } + } + u.render(w, r, "recognition_units", data) } func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - asset := service.RecognitionUnitAsset{ - SceneTemplateName: strings.TrimSpace(r.FormValue("scene_template_name")), - Name: strings.TrimSpace(r.FormValue("name")), - DisplayName: strings.TrimSpace(r.FormValue("display_name")), - SiteName: strings.TrimSpace(r.FormValue("site_name")), - VideoSourceRef: strings.TrimSpace(r.FormValue("video_source_ref")), - OutputChannel: strings.TrimSpace(r.FormValue("output_channel")), - RTSPPort: strings.TrimSpace(r.FormValue("rtsp_port")), - } - originalRef := strings.TrimSpace(r.FormValue("original_ref")) - if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil { - data := u.assetPageData("") - data.Title = "视频通道" - data.Error = err.Error() - data.RecognitionUnit = &asset - data.RecognitionUnitEditing = true - data.SelectedRecognitionUnit = originalRef - u.render(w, r, "recognition_units", data) - return - } - ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name) - http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound) + _ = r.ParseForm() + asset := service.RecognitionUnitAsset{ + SceneTemplateName: strings.TrimSpace(r.FormValue("scene_template_name")), + Name: strings.TrimSpace(r.FormValue("name")), + DisplayName: strings.TrimSpace(r.FormValue("display_name")), + SiteName: strings.TrimSpace(r.FormValue("site_name")), + VideoSourceRef: strings.TrimSpace(r.FormValue("video_source_ref")), + OutputChannel: strings.TrimSpace(r.FormValue("output_channel")), + RTSPPort: strings.TrimSpace(r.FormValue("rtsp_port")), + } + originalRef := strings.TrimSpace(r.FormValue("original_ref")) + if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil { + data := u.assetPageData("") + data.Title = "视频通道" + data.Error = err.Error() + data.RecognitionUnit = &asset + data.RecognitionUnitEditing = true + data.SelectedRecognitionUnit = originalRef + u.render(w, r, "recognition_units", data) + return + } + ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name) + http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound) } func serviceRecognitionUnitRef(profileName string, unitName string) string { - return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) + return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) } func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request) { - ref := strings.TrimSpace(r.FormValue("ref")) - if err := u.preview.DeleteRecognitionUnit(ref); err != nil { - http.Redirect(w, r, "/ui/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound) + ref := strings.TrimSpace(r.FormValue("ref")) + if err := u.preview.DeleteRecognitionUnit(ref); err != nil { + http.Redirect(w, r, "/ui/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound) } func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) { - data := u.assetPageData("") - data.Title = "通道部署" - data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) - if data.Error == "" { - data.Error = strings.TrimSpace(r.URL.Query().Get("error")) - } - u.ensureDevicesLoaded() - data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) - maxUnits := service.DefaultMaxUnitsPerDevice() - if value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("max_units_per_device"))); err == nil && value > 0 { - maxUnits = value - } - data.MaxUnitsPerDevice = maxUnits - if u.preview != nil { - if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil { - data.DeviceAssignmentBoard = board - if payload, marshalErr := json.Marshal(board); marshalErr == nil { - data.DeviceAssignmentBoardJSON = template.JS(payload) - } - } else if data.Error == "" { - data.Error = err.Error() - } - } - u.render(w, r, "device_assignments", data) + data := u.assetPageData("") + data.Title = "通道部署" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + u.ensureDevicesLoaded() + data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) + maxUnits := service.DefaultMaxUnitsPerDevice() + if value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("max_units_per_device"))); err == nil && value > 0 { + maxUnits = value + } + data.MaxUnitsPerDevice = maxUnits + if u.preview != nil { + if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil { + data.DeviceAssignmentBoard = board + if payload, marshalErr := json.Marshal(board); marshalErr == nil { + data.DeviceAssignmentBoardJSON = template.JS(payload) + } + } else if data.Error == "" { + data.Error = err.Error() + } + } + u.render(w, r, "device_assignments", data) } func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() - assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json"))) - if err != nil { - data := u.assetPageData("") - data.Title = "通道部署" - u.ensureDevicesLoaded() - data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) - data.Error = err.Error() - maxUnits := service.DefaultMaxUnitsPerDevice() - if value, convErr := strconv.Atoi(strings.TrimSpace(r.FormValue("max_units_per_device"))); convErr == nil && value > 0 { - maxUnits = value - } - data.MaxUnitsPerDevice = maxUnits - if u.preview != nil { - if board, buildErr := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); buildErr == nil { - data.DeviceAssignmentBoard = board - if payload, marshalErr := json.Marshal(board); marshalErr == nil { - data.DeviceAssignmentBoardJSON = template.JS(payload) - } - } - } - u.render(w, r, "device_assignments", data) - return - } - if err := u.preview.SaveDeviceAssignmentBoard(filterPreviewDeviceAssignments(assignments)); err != nil { - http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound) + _ = r.ParseForm() + assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json"))) + if err != nil { + data := u.assetPageData("") + data.Title = "通道部署" + u.ensureDevicesLoaded() + data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) + data.Error = err.Error() + maxUnits := service.DefaultMaxUnitsPerDevice() + if value, convErr := strconv.Atoi(strings.TrimSpace(r.FormValue("max_units_per_device"))); convErr == nil && value > 0 { + maxUnits = value + } + data.MaxUnitsPerDevice = maxUnits + if u.preview != nil { + if board, buildErr := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); buildErr == nil { + data.DeviceAssignmentBoard = board + if payload, marshalErr := json.Marshal(board); marshalErr == nil { + data.DeviceAssignmentBoardJSON = template.JS(payload) + } + } + } + u.render(w, r, "device_assignments", data) + return + } + if err := u.preview.SaveDeviceAssignmentBoard(filterPreviewDeviceAssignments(assignments)); err != nil { + http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound) } func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device { - out := make([]*models.Device, 0, len(devices)) - seen := make(map[string]struct{}, len(devices)) - for _, dev := range devices { - if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { - continue - } - deviceID := strings.TrimSpace(dev.DeviceID) - if _, ok := seen[deviceID]; ok { - continue - } - seen[deviceID] = struct{}{} - out = append(out, dev) - } - for len(out) < deviceAssignmentPreviewDeviceCount { - index := len(out) + 1 - deviceID := fmt.Sprintf("%s%02d", deviceAssignmentPreviewDevicePrefix, index) - out = append(out, &models.Device{ - DeviceID: deviceID, - DeviceName: fmt.Sprintf("测试设备 %02d", index), - Online: true, - }) - } - return out + out := make([]*models.Device, 0, len(devices)) + seen := make(map[string]struct{}, len(devices)) + for _, dev := range devices { + if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { + continue + } + deviceID := strings.TrimSpace(dev.DeviceID) + if _, ok := seen[deviceID]; ok { + continue + } + seen[deviceID] = struct{}{} + out = append(out, dev) + } + for len(out) < deviceAssignmentPreviewDeviceCount { + index := len(out) + 1 + deviceID := fmt.Sprintf("%s%02d", deviceAssignmentPreviewDevicePrefix, index) + out = append(out, &models.Device{ + DeviceID: deviceID, + DeviceName: fmt.Sprintf("测试设备 %02d", index), + Online: true, + }) + } + return out } func filterPreviewDeviceAssignments(assignments map[string][]string) map[string][]string { - if len(assignments) == 0 { - return assignments - } - filtered := make(map[string][]string, len(assignments)) - for deviceID, refs := range assignments { - if strings.HasPrefix(strings.TrimSpace(deviceID), deviceAssignmentPreviewDevicePrefix) { - continue - } - filtered[deviceID] = refs - } - return filtered + if len(assignments) == 0 { + return assignments + } + filtered := make(map[string][]string, len(assignments)) + for deviceID, refs := range assignments { + if strings.HasPrefix(strings.TrimSpace(deviceID), deviceAssignmentPreviewDevicePrefix) { + continue + } + filtered[deviceID] = refs + } + return filtered } func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request) { - deviceID := strings.TrimSpace(chi.URLParam(r, "id")) - if err := u.preview.DeleteDeviceAssignment(deviceID); err != nil { - http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound) + deviceID := strings.TrimSpace(chi.URLParam(r, "id")) + if err := u.preview.DeleteDeviceAssignment(deviceID); err != nil { + http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound) } func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) { - data := u.assetPageData("video-sources") - 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.GetVideoSource(selected); err == nil { - data.AssetVideoSource = item - } else if data.Error == "" { - data.Error = err.Error() - } - } else if !newMode && len(data.AssetVideoSources) > 0 { - if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil { - data.AssetVideoSource = item - } - } - if data.AssetVideoSource == nil { - data.AssetVideoSource = &service.ConfigVideoSourceAsset{ - SourceType: "rtsp", - SourceTypeLabel: "RTSP", - } - data.AssetVideoSourceEditing = true - } else { - data.AssetVideoSourceEditing = newMode || editMode - } - u.render(w, r, "assets", data) + data := u.assetPageData("video-sources") + 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.GetVideoSource(selected); err == nil { + data.AssetVideoSource = item + } else if data.Error == "" { + data.Error = err.Error() + } + } else if !newMode && len(data.AssetVideoSources) > 0 { + if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil { + data.AssetVideoSource = item + } + } + if data.AssetVideoSource == nil { + data.AssetVideoSource = &service.ConfigVideoSourceAsset{ + SourceType: "rtsp", + SourceTypeLabel: "RTSP", + } + data.AssetVideoSourceEditing = true + } else { + data.AssetVideoSourceEditing = newMode || editMode + } + u.render(w, r, "assets", data) } func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request) { - if u.preview == nil { - http.Error(w, "config preview service is not configured", http.StatusInternalServerError) - return - } - _ = r.ParseForm() - asset := service.ConfigVideoSourceAsset{ - Name: strings.TrimSpace(r.FormValue("name")), - SourceType: strings.TrimSpace(r.FormValue("source_type")), - Area: strings.TrimSpace(r.FormValue("area")), - Description: strings.TrimSpace(r.FormValue("description")), - Config: service.VideoSourceConfig{ - URL: strings.TrimSpace(r.FormValue("url")), - Resolution: strings.TrimSpace(r.FormValue("resolution")), - FrameSize: strings.TrimSpace(r.FormValue("frame_size")), - FPS: strings.TrimSpace(r.FormValue("fps")), - VideoFormat: strings.TrimSpace(r.FormValue("video_format")), - FocalLength: strings.TrimSpace(r.FormValue("focal_length")), - MountHeight: strings.TrimSpace(r.FormValue("mount_height")), - MountAngle: strings.TrimSpace(r.FormValue("mount_angle")), - }, - } - if err := u.preview.SaveVideoSourceAsset(asset); err != nil { - data := u.assetPageData("video-sources") - data.Title = "配置中心" - data.Error = err.Error() - data.AssetVideoSource = &asset - data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType) - data.AssetVideoSourceEditing = true - u.render(w, r, "assets", data) - return - } - http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound) + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + _ = r.ParseForm() + asset := service.ConfigVideoSourceAsset{ + Name: strings.TrimSpace(r.FormValue("name")), + SourceType: strings.TrimSpace(r.FormValue("source_type")), + Area: strings.TrimSpace(r.FormValue("area")), + Description: strings.TrimSpace(r.FormValue("description")), + Config: service.VideoSourceConfig{ + URL: strings.TrimSpace(r.FormValue("url")), + Resolution: strings.TrimSpace(r.FormValue("resolution")), + FrameSize: strings.TrimSpace(r.FormValue("frame_size")), + FPS: strings.TrimSpace(r.FormValue("fps")), + VideoFormat: strings.TrimSpace(r.FormValue("video_format")), + FocalLength: strings.TrimSpace(r.FormValue("focal_length")), + MountHeight: strings.TrimSpace(r.FormValue("mount_height")), + MountAngle: strings.TrimSpace(r.FormValue("mount_angle")), + }, + } + if err := u.preview.SaveVideoSourceAsset(asset); err != nil { + data := u.assetPageData("video-sources") + data.Title = "配置中心" + data.Error = err.Error() + data.AssetVideoSource = &asset + data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType) + data.AssetVideoSourceEditing = true + u.render(w, r, "assets", data) + return + } + http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound) } func (u *UI) actionAssetVideoSourceDelete(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.DeleteVideoSource(name); err != nil { - http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound) + 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.DeleteVideoSource(name); err != nil { + http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound) } 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 - } - u.render(w, r, "assets", data) + 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 + } + 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") - asset := service.ConfigIntegrationServiceAsset{ - Name: strings.TrimSpace(r.FormValue("name")), - Type: strings.TrimSpace(r.FormValue("type")), - Description: strings.TrimSpace(r.FormValue("description")), - Enabled: enabled, - ObjectStorage: &service.ObjectStorageConfig{ - Endpoint: strings.TrimSpace(r.FormValue("endpoint")), - Bucket: strings.TrimSpace(r.FormValue("bucket")), - AccessKey: strings.TrimSpace(r.FormValue("access_key")), - SecretKey: strings.TrimSpace(r.FormValue("secret_key")), - }, - TokenService: &service.TokenServiceConfig{ - GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")), - Username: strings.TrimSpace(r.FormValue("username")), - Password: strings.TrimSpace(r.FormValue("password")), - TenantCode: strings.TrimSpace(r.FormValue("tenant_code")), - }, - AlarmService: &service.AlarmServiceConfig{ - PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")), - Username: strings.TrimSpace(r.FormValue("alarm_username")), - Password: strings.TrimSpace(r.FormValue("alarm_password")), - TenantCode: strings.TrimSpace(r.FormValue("alarm_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) + 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") + asset := service.ConfigIntegrationServiceAsset{ + Name: strings.TrimSpace(r.FormValue("name")), + Type: strings.TrimSpace(r.FormValue("type")), + Description: strings.TrimSpace(r.FormValue("description")), + Enabled: enabled, + ObjectStorage: &service.ObjectStorageConfig{ + Endpoint: strings.TrimSpace(r.FormValue("endpoint")), + Bucket: strings.TrimSpace(r.FormValue("bucket")), + AccessKey: strings.TrimSpace(r.FormValue("access_key")), + SecretKey: strings.TrimSpace(r.FormValue("secret_key")), + }, + TokenService: &service.TokenServiceConfig{ + GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")), + Username: strings.TrimSpace(r.FormValue("username")), + Password: strings.TrimSpace(r.FormValue("password")), + TenantCode: strings.TrimSpace(r.FormValue("tenant_code")), + }, + AlarmService: &service.AlarmServiceConfig{ + PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")), + Username: strings.TrimSpace(r.FormValue("alarm_username")), + Password: strings.TrimSpace(r.FormValue("alarm_password")), + TenantCode: strings.TrimSpace(r.FormValue("alarm_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) + 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" - if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { - 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 { - data.AssetOverlay = &service.ConfigOverlayAsset{ - Name: "", - Description: "", - Raw: map[string]any{ - "name": "", - "description": "", - "instance_overrides": map[string]any{"*": map[string]any{"override": map[string]any{}}}, - }, - } - data.AssetOverlayEditing = true - } else { - data.AssetOverlayEditing = editMode && data.AssetOverlay != nil - } - if data.AssetOverlay != nil { - rawJSON, err := compactJSON(data.AssetOverlay.Raw) - if err == nil { - data.OverlayDraftJSON = rawJSON - } - } - u.render(w, r, "asset_overlays", data) + 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" + if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { + 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 { + data.AssetOverlay = &service.ConfigOverlayAsset{ + Name: "", + Description: "", + Raw: map[string]any{ + "name": "", + "description": "", + "instance_overrides": map[string]any{"*": map[string]any{"override": map[string]any{}}}, + }, + } + data.AssetOverlayEditing = true + } else { + data.AssetOverlayEditing = editMode && data.AssetOverlay != nil + } + 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) + 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")) + 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) + 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) 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) + 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 + 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 + 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 + 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 + 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 + 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) + 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) + 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")), - ) + 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) + 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, - }) + 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) + 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) + 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 + 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) - } + 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 + 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 + 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() + 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 + 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) + 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 + 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)) + 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}) + 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) + 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) + 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) + 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) + 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 + 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 + 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 + 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() + 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 + 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 + 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 + 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}, - } + 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) + 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 - } - } + 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, - } + 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 + 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 + 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 + 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 + 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) + 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 "" - } + 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 "" + 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 + 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 + 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() + 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 + 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 + 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 + 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 + 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)), - } + 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 - } - } - } + 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 "" + 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) + 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, ";") + 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 - } + 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) + 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 - } + 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) + 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() + 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) + 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) + 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()}) + 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) + 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) + // 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) + 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) + 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) + 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) + 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) + 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() + 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 + 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]) + 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) + 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) + 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) + 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}) + 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) + 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"` + Name string `json:"name"` + CPU float64 `json:"cpu"` + Mem float64 `json:"mem"` + NPU float64 `json:"npu"` } diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index 9afe952..bdc9b42 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -1,536 +1,536 @@ package web import ( - "3588AdminBackend/internal/config" - "3588AdminBackend/internal/models" - "3588AdminBackend/internal/service" - "3588AdminBackend/internal/storage" - "bytes" - "context" - "encoding/json" - "fmt" - "mime/multipart" - "net" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" + "3588AdminBackend/internal/config" + "3588AdminBackend/internal/models" + "3588AdminBackend/internal/service" + "3588AdminBackend/internal/storage" + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" - "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5" ) func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) { - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - reg := service.NewRegistryService(cfg, nil) - reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: "127.0.0.1", AgentPort: 9100, Online: true}) - reg.UpdateDevice(&models.Device{DeviceID: "dev2", IP: "127.0.0.1", AgentPort: 9100, Online: true}) - tasks := service.NewTaskService(cfg, nil, reg) + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + reg := service.NewRegistryService(cfg, nil) + reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: "127.0.0.1", AgentPort: 9100, Online: true}) + reg.UpdateDevice(&models.Device{DeviceID: "dev2", IP: "127.0.0.1", AgentPort: 9100, Online: true}) + tasks := service.NewTaskService(cfg, nil, reg) - ui, err := NewUI(nil, reg, nil, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + ui, err := NewUI(nil, reg, nil, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - form := url.Values{} - form.Set("action", "media_start") - form.Add("device_id", "dev1") - form.Add("device_id", "dev2") - form.Set("config", "cam1") + form := url.Values{} + form.Set("action", "media_start") + form.Add("device_id", "dev1") + form.Add("device_id", "dev2") + form.Set("config", "cam1") - req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionDevicesBatchAction(rr, req) + ui.actionDevicesBatchAction(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String()) - } - loc := rr.Header().Get("Location") - if !strings.HasPrefix(loc, "/ui/tasks/") { - t.Fatalf("expected redirect to /ui/tasks/*, got %q", loc) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String()) + } + loc := rr.Header().Get("Location") + if !strings.HasPrefix(loc, "/ui/tasks/") { + t.Fatalf("expected redirect to /ui/tasks/*, got %q", loc) + } } func TestUI_ActionDevicesBatchActionDeduplicatesKnownDevices(t *testing.T) { - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - form := url.Values{} - form.Set("action", "reload") - form.Add("device_id", "edge-01") - form.Add("device_id", "edge-01") - form.Add("device_id", "edge-02") - form.Add("device_id", "missing") - req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("action", "reload") + form.Add("device_id", "edge-01") + form.Add("device_id", "edge-01") + form.Add("device_id", "edge-02") + form.Add("device_id", "missing") + req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionDevicesBatchAction(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } + ui.actionDevicesBatchAction(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } - loc := rr.Header().Get("Location") - if !strings.HasPrefix(loc, "/ui/tasks/") { - t.Fatalf("expected redirect to task page, got %q", loc) - } + loc := rr.Header().Get("Location") + if !strings.HasPrefix(loc, "/ui/tasks/") { + t.Fatalf("expected redirect to task page, got %q", loc) + } - taskID := strings.TrimPrefix(loc, "/ui/tasks/") - items := ui.tasks.ListTasks() - var task *models.Task - for i := range items { - if items[i].ID == taskID { - t := items[i] - task = &t - break - } - } - if task == nil { - t.Fatalf("expected task %s to exist", taskID) - } - if got := len(task.DeviceIDs); got != 2 { - t.Fatalf("expected deduplicated device count 2, got %d: %#v", got, task.DeviceIDs) - } - if task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" { - t.Fatalf("expected selection order preserved, got %#v", task.DeviceIDs) - } + taskID := strings.TrimPrefix(loc, "/ui/tasks/") + items := ui.tasks.ListTasks() + var task *models.Task + for i := range items { + if items[i].ID == taskID { + t := items[i] + task = &t + break + } + } + if task == nil { + t.Fatalf("expected task %s to exist", taskID) + } + if got := len(task.DeviceIDs); got != 2 { + t.Fatalf("expected deduplicated device count 2, got %d: %#v", got, task.DeviceIDs) + } + if task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" { + t.Fatalf("expected selection order preserved, got %#v", task.DeviceIDs) + } } func TestUI_SelectedDeviceQueryHelpersStayStable(t *testing.T) { - ids := selectedIDsFromQuery([]string{" edge-02 ", "edge-01", "edge-02", ""}) - if len(ids) != 2 || ids[0] != "edge-02" || ids[1] != "edge-01" { - t.Fatalf("selectedIDsFromQuery normalized to %#v", ids) - } - if got := selectedQueryString(ids); got != "selected=edge-02&selected=edge-01" { - t.Fatalf("selectedQueryString returned %q", got) - } + ids := selectedIDsFromQuery([]string{" edge-02 ", "edge-01", "edge-02", ""}) + if len(ids) != 2 || ids[0] != "edge-02" || ids[1] != "edge-01" { + t.Fatalf("selectedIDsFromQuery normalized to %#v", ids) + } + if got := selectedQueryString(ids); got != "selected=edge-02&selected=edge-01" { + t.Fatalf("selectedQueryString returned %q", got) + } } func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) { - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - reg := service.NewRegistryService(cfg, nil) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true}) - tasks := service.NewTaskService(cfg, nil, reg) + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + reg := service.NewRegistryService(cfg, nil) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true}) + tasks := service.NewTaskService(cfg, nil, reg) - ui, err := NewUI(nil, reg, nil, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + ui, err := NewUI(nil, reg, nil, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) + rr := httptest.NewRecorder() - ui.pageDevices(rr, req) + ui.pageDevices(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{ - "视觉识别运维平台", - "总览", - "场景", - "视频通道", - "通道部署", - "配置中心", - "任务中心", - "系统管理", - "

设备

", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected rendered HTML to contain %q, got:\n%s", want, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "视觉识别运维平台", + "总览", + "场景", + "视频通道", + "通道部署", + "配置中心", + "任务中心", + "系统管理", + "

设备

", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected rendered HTML to contain %q, got:\n%s", want, body) + } + } } func TestUI_LayoutProvidesThemeSwitcherAndCompactTopbar(t *testing.T) { - ui := newTestUI(t) + ui := newTestUI(t) - body := renderPage(t, ui, "/ui/devices") + body := renderPage(t, ui, "/ui/devices") - for _, want := range []string{ - `data-theme="blue-dark"`, - `class="topbar-actions"`, - `data-theme-option="blue-dark"`, - `data-theme-option="blue-light"`, - `data-theme-option="graphite-gold"`, - `const themeLabels = {`, - `"blue-light": "蓝灰浅色"`, - `"当前主题:" + themeLabels[nextTheme]`, - `aria-label="主题"`, - `aria-label="日志审计"`, - `aria-label="系统"`, - `localStorage.getItem("3588-admin-theme")`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected layout to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{ - `class="crumb"`, - "多设备视觉识别运维平台", - "Fleet Operations Console", - } { - if strings.Contains(body, forbidden) { - t.Fatalf("expected compact topbar to omit %q, got:\n%s", forbidden, body) - } - } + for _, want := range []string{ + `data-theme="blue-dark"`, + `class="topbar-actions"`, + `data-theme-option="blue-dark"`, + `data-theme-option="blue-light"`, + `data-theme-option="graphite-gold"`, + `const themeLabels = {`, + `"blue-light": "蓝灰浅色"`, + `"当前主题:" + themeLabels[nextTheme]`, + `aria-label="主题"`, + `aria-label="日志审计"`, + `aria-label="系统"`, + `localStorage.getItem("3588-admin-theme")`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected layout to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{ + `class="crumb"`, + "多设备视觉识别运维平台", + "Fleet Operations Console", + } { + if strings.Contains(body, forbidden) { + t.Fatalf("expected compact topbar to omit %q, got:\n%s", forbidden, body) + } + } } func TestUI_ConsoleTypographyStaysModerate(t *testing.T) { - css, err := os.ReadFile("ui/assets/style.css") - if err != nil { - t.Fatalf("read stylesheet: %v", err) - } - text := string(css) - for _, want := range []string{ - `body[data-theme="blue-light"]`, - `body[data-theme="graphite-gold"]`, - "--radius:4px", - ".nav-section{padding:14px 10px 6px;font-size:11px", - ".side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500}", - ".topbar h1{margin:0;font-size:18px;font-weight:600", - ".card h2{margin:0 0 8px;font-size:15px;font-weight:600;color:var(--text)}", - ".card h3{margin:0 0 6px;font-size:13px;font-weight:600;color:var(--text)}", - ".btn.ghost.icon-only{background:transparent;border-color:transparent;color:var(--muted)}", - ".topbar{height:60px;background:var(--topbar);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:100}", - ".topbar-icon-btn{position:relative", - ".theme-menu-panel{position:fixed;right:28px;top:54px;", - "z-index:9999", - ".btn,button{display:inline-flex", - ".stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}", - ".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)", - } { - if !strings.Contains(text, want) { - t.Fatalf("expected stylesheet to contain %q", want) - } - } + css, err := os.ReadFile("ui/assets/style.css") + if err != nil { + t.Fatalf("read stylesheet: %v", err) + } + text := string(css) + for _, want := range []string{ + `body[data-theme="blue-light"]`, + `body[data-theme="graphite-gold"]`, + "--radius:4px", + ".nav-section{padding:14px 10px 6px;font-size:11px", + ".side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500}", + ".topbar h1{margin:0;font-size:18px;font-weight:600", + ".card h2{margin:0 0 8px;font-size:15px;font-weight:600;color:var(--text)}", + ".card h3{margin:0 0 6px;font-size:13px;font-weight:600;color:var(--text)}", + ".btn.ghost.icon-only{background:transparent;border-color:transparent;color:var(--muted)}", + ".topbar{height:60px;background:var(--topbar);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:100}", + ".topbar-icon-btn{position:relative", + ".theme-menu-panel{position:fixed;right:28px;top:54px;", + "z-index:9999", + ".btn,button{display:inline-flex", + ".stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}", + ".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected stylesheet to contain %q", want) + } + } } func TestUI_BlueDarkThemeKeepsWorkspaceDarkAndTablesReadable(t *testing.T) { - css, err := os.ReadFile("ui/assets/style.css") - if err != nil { - t.Fatalf("read stylesheet: %v", err) - } - text := string(css) - for _, want := range []string{ - "--bg:#090909", - "--surface:#121212", - "--surface-soft:#1a1a1a", - "--table-text:#c8c8c8", - "--table-link:#f4f4f4", - "--primary:#dddddd", - "--selected-row:#303030", - "--border:#303030", - "--border-strong:#565656", - "--button-soft:#1d1d1d", - "--button-soft-hover:#292929", - "--input-bg:#101010", - "--green:#66c98f", - "--amber:#d8a657", - "--red:#e46f72", - ".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:14px 0;box-shadow:var(--shadow);color:var(--text)}", - ".form-hint{color:var(--muted);font-size:12px;line-height:1.35}", - "th,td{padding:8px 12px;border-bottom:1px solid var(--border);text-align:left;vertical-align:middle;line-height:1.25;color:var(--table-text)}", - ".table-wrap td a{color:var(--table-link)}", - ".device-name{font-size:13px;font-weight:400;color:var(--table-link)}", - ".asset-link>span:first-child{color:var(--table-link)}", - } { - if !strings.Contains(text, want) { - t.Fatalf("expected blue dark theme stylesheet to contain %q", want) - } - } + css, err := os.ReadFile("ui/assets/style.css") + if err != nil { + t.Fatalf("read stylesheet: %v", err) + } + text := string(css) + for _, want := range []string{ + "--bg:#090909", + "--surface:#121212", + "--surface-soft:#1a1a1a", + "--table-text:#c8c8c8", + "--table-link:#f4f4f4", + "--primary:#dddddd", + "--selected-row:#303030", + "--border:#303030", + "--border-strong:#565656", + "--button-soft:#1d1d1d", + "--button-soft-hover:#292929", + "--input-bg:#101010", + "--green:#66c98f", + "--amber:#d8a657", + "--red:#e46f72", + ".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:14px 0;box-shadow:var(--shadow);color:var(--text)}", + ".form-hint{color:var(--muted);font-size:12px;line-height:1.35}", + "th,td{padding:8px 12px;border-bottom:1px solid var(--border);text-align:left;vertical-align:middle;line-height:1.25;color:var(--table-text)}", + ".table-wrap td a{color:var(--table-link)}", + ".device-name{font-size:13px;font-weight:400;color:var(--table-link)}", + ".asset-link>span:first-child{color:var(--table-link)}", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected blue dark theme stylesheet to contain %q", want) + } + } } func newTestUI(t *testing.T) *UI { - t.Helper() - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - t.Cleanup(func() { _ = store.Close() }) - reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0"}) - tasks := service.NewTaskService(cfg, nil, reg) - ui, err := NewUI(nil, reg, nil, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } - return ui + t.Helper() + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0"}) + tasks := service.NewTaskService(cfg, nil, reg) + ui, err := NewUI(nil, reg, nil, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + return ui } func renderPage(t *testing.T, ui *UI, path string) string { - t.Helper() - router, err := ui.Routes() - if err != nil { - t.Fatalf("build routes: %v", err) - } - if strings.HasPrefix(path, "/ui/") { - path = strings.TrimPrefix(path, "/ui") - } - req := httptest.NewRequest(http.MethodGet, path, nil) - rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200 for %s, got %d: %s", path, rr.Code, rr.Body.String()) - } - return rr.Body.String() + t.Helper() + router, err := ui.Routes() + if err != nil { + t.Fatalf("build routes: %v", err) + } + if strings.HasPrefix(path, "/ui/") { + path = strings.TrimPrefix(path, "/ui") + } + req := httptest.NewRequest(http.MethodGet, path, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 for %s, got %d: %s", path, rr.Code, rr.Body.String()) + } + return rr.Body.String() } func TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) { - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) + rr := httptest.NewRecorder() - ui.pageDevices(rr, req) + ui.pageDevices(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载运行配置", "回滚运行配置", "下发设备分配", "清空选择"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, forbidden := range []string{"batch-toolbar", "已选", "重启服务", "启动服务", "停止服务", "重载运行配置", "回滚运行配置", "下发设备分配", "清空选择"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body) + } + } } func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) { - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - req := httptest.NewRequest(http.MethodGet, "/ui/devices?selected=edge-01&selected=edge-02", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + req := httptest.NewRequest(http.MethodGet, "/ui/devices?selected=edge-01&selected=edge-02", nil) + rr := httptest.NewRecorder() - ui.pageDevices(rr, req) + ui.pageDevices(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载运行配置", "回滚运行配置", "下发设备分配", "清空选择", "将重载当前运行配置", "将回滚到上一版运行配置"} { - if !strings.Contains(body, want) { - t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"批量配置", "重载服务"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device overview batch controls should not contain %q, got:\n%s", forbidden, body) - } - } - if !strings.Contains(body, `href="/ui/devices/batch-config?selected=edge-01&selected=edge-02"`) { - t.Fatalf("device overview batch config link should preserve selected query params, got:\n%s", body) - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载运行配置", "回滚运行配置", "下发设备分配", "清空选择", "将重载当前运行配置", "将回滚到上一版运行配置"} { + if !strings.Contains(body, want) { + t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"批量配置", "重载服务"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device overview batch controls should not contain %q, got:\n%s", forbidden, body) + } + } + if !strings.Contains(body, `href="/ui/devices/batch-config?selected=edge-01&selected=edge-02"`) { + t.Fatalf("device overview batch config link should preserve selected query params, got:\n%s", body) + } } func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) { - t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply") - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo) - mustImportAssetsForUI(t, ui.preview) - mustImportAssetsForUI(t, ui.preview) + t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply") + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo) + mustImportAssetsForUI(t, ui.preview) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/devices/batch-config?selected=edge-01&selected=edge-02", nil) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ui/devices/batch-config?selected=edge-01&selected=edge-02", nil) + rr := httptest.NewRecorder() - ui.pageDeviceBatchConfig(rr, req) + ui.pageDeviceBatchConfig(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{ - "下发当前设备分配", - "场景配置", - "已选设备", - "入口识别节点", - "辅助节点", - "场景配置摘要", - "local_3588_test", - "A厂区视觉识别", - "std_workshop_face_recognition_shoe_alarm", - "东门入口", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"config_id", "config_version", "完整 JSON"} { - if strings.Contains(body, forbidden) { - t.Fatalf("batch config page should not expose internal config fields %q, got:\n%s", forbidden, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "下发当前设备分配", + "场景配置", + "已选设备", + "入口识别节点", + "辅助节点", + "场景配置摘要", + "local_3588_test", + "A厂区视觉识别", + "std_workshop_face_recognition_shoe_alarm", + "东门入口", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"config_id", "config_version", "完整 JSON"} { + if strings.Contains(body, forbidden) { + t.Fatalf("batch config page should not expose internal config fields %q, got:\n%s", forbidden, body) + } + } } func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(t *testing.T) { - t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply") - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo) - mustImportAssetsForUI(t, ui.preview) - mustImportAssetsForUI(t, ui.preview) + t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply") + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo) + mustImportAssetsForUI(t, ui.preview) + mustImportAssetsForUI(t, ui.preview) - form := url.Values{} - form.Add("device_id", "edge-01") - form.Add("device_id", "edge-02") - form.Set("profile", "local_3588_test") - req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Add("device_id", "edge-01") + form.Add("device_id", "edge-02") + form.Set("profile", "local_3588_test") + req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionDeviceBatchConfig(rr, req) + ui.actionDeviceBatchConfig(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - loc := rr.Header().Get("Location") - if !strings.HasPrefix(loc, "/ui/tasks/") { - t.Fatalf("expected redirect to task page, got %q", loc) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + loc := rr.Header().Get("Location") + if !strings.HasPrefix(loc, "/ui/tasks/") { + t.Fatalf("expected redirect to task page, got %q", loc) + } - taskID := strings.TrimPrefix(loc, "/ui/tasks/") - items := ui.tasks.ListTasks() - var task *models.Task - for i := range items { - if items[i].ID == taskID { - t := items[i] - task = &t - break - } - } - if task == nil { - t.Fatalf("expected task %s to exist", taskID) - } - if task.Type != "config_apply" { - t.Fatalf("expected task type config_apply, got %q", task.Type) - } - if len(task.DeviceIDs) != 2 || task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" { - t.Fatalf("expected selected devices preserved, got %#v", task.DeviceIDs) - } - payload, ok := task.Payload.(map[string]any) - if !ok { - t.Fatalf("expected payload map, got %#v", task.Payload) - } - configDoc, ok := payload["config"].(map[string]any) - if !ok { - t.Fatalf("expected payload.config object, got %#v", payload["config"]) - } - metadata, ok := configDoc["metadata"].(map[string]any) - if !ok { - t.Fatalf("expected metadata object, got %#v", configDoc["metadata"]) - } - if metadata["template"] != "std_workshop_face_recognition_shoe_alarm" { - t.Fatalf("expected template metadata, got %#v", metadata["template"]) - } - if metadata["profile"] != "local_3588_test" { - t.Fatalf("expected profile metadata, got %#v", metadata["profile"]) - } + taskID := strings.TrimPrefix(loc, "/ui/tasks/") + items := ui.tasks.ListTasks() + var task *models.Task + for i := range items { + if items[i].ID == taskID { + t := items[i] + task = &t + break + } + } + if task == nil { + t.Fatalf("expected task %s to exist", taskID) + } + if task.Type != "config_apply" { + t.Fatalf("expected task type config_apply, got %q", task.Type) + } + if len(task.DeviceIDs) != 2 || task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" { + t.Fatalf("expected selected devices preserved, got %#v", task.DeviceIDs) + } + payload, ok := task.Payload.(map[string]any) + if !ok { + t.Fatalf("expected payload map, got %#v", task.Payload) + } + configDoc, ok := payload["config"].(map[string]any) + if !ok { + t.Fatalf("expected payload.config object, got %#v", payload["config"]) + } + metadata, ok := configDoc["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata object, got %#v", configDoc["metadata"]) + } + if metadata["template"] != "std_workshop_face_recognition_shoe_alarm" { + t.Fatalf("expected template metadata, got %#v", metadata["template"]) + } + if metadata["profile"] != "local_3588_test" { + t.Fatalf("expected profile metadata, got %#v", metadata["profile"]) + } } func TestUI_ActionDeviceBatchConfigRenderFailurePreservesUserInput(t *testing.T) { - t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply") - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigBrokenMediaRepo(t)}, repo) - mustImportAssetsForUI(t, ui.preview) - mustImportAssetsForUI(t, ui.preview) + t.Skip("legacy batch scene-config flow replaced by device-assignment batch apply") + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigBrokenMediaRepo(t)}, repo) + mustImportAssetsForUI(t, ui.preview) + mustImportAssetsForUI(t, ui.preview) - form := url.Values{} - form.Add("device_id", "edge-01") - form.Add("device_id", "edge-02") - form.Set("profile", "local_3588_test") - req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Add("device_id", "edge-01") + form.Add("device_id", "edge-02") + form.Set("profile", "local_3588_test") + req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionDeviceBatchConfig(rr, req) + ui.actionDeviceBatchConfig(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{ - `name="device_id" value="edge-01"`, - `name="device_id" value="edge-02"`, - "入口识别节点", - "辅助节点", - `name="profile"`, - `value="local_3588_test" selected`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{`name="config_id"`, `name="overlay"`, "完整 JSON"} { - if strings.Contains(body, forbidden) { - t.Fatalf("expected failure UI to avoid internal config field %q, got:\n%s", forbidden, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + `name="device_id" value="edge-01"`, + `name="device_id" value="edge-02"`, + "入口识别节点", + "辅助节点", + `name="profile"`, + `value="local_3588_test" selected`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{`name="config_id"`, `name="overlay"`, "完整 JSON"} { + if strings.Contains(body, forbidden) { + t.Fatalf("expected failure UI to avoid internal config field %q, got:\n%s", forbidden, body) + } + } } func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) { - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - form := url.Values{} - form.Set("action", "nope") - form.Add("device_id", "edge-01") - form.Add("device_id", "edge-02") - req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("action", "nope") + form.Add("device_id", "edge-01") + form.Add("device_id", "edge-02") + req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionDevicesBatchAction(rr, req) + ui.actionDevicesBatchAction(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"不支持的操作: nope", "入口识别节点", "辅助节点", "已选 2 台", `value="edge-01" checked`, `value="edge-02" checked`} { - if !strings.Contains(body, want) { - t.Fatalf("expected error render to contain %q, got:\n%s", want, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"不支持的操作: nope", "入口识别节点", "辅助节点", "已选 2 台", `value="edge-01" checked`, `value="edge-02" checked`} { + if !strings.Contains(body, want) { + t.Fatalf("expected error render to contain %q, got:\n%s", want, body) + } + } } func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) { - t.Skip("legacy scene-config editor replaced by scene-template and recognition-unit pages") - ui := newTestUI(t) - root := createProfileEditorMediaRepo(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ + t.Skip("legacy scene-config editor replaced by scene-template and recognition-unit pages") + ui := newTestUI(t) + root := createProfileEditorMediaRepo(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ "name": "std_workshop_face_recognition_shoe_alarm", "source": "standard", "slots": { @@ -548,189 +548,189 @@ func TestUI_AssetProfilePageShowsEditorTabs(t *testing.T) { }, "template": {"nodes": [], "edges": []} }`) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) - mustSaveVideoSource(t, repo, "line_cam_02", `{"name":"line_cam_02","source_type":"rtsp","area":"西门入口","config":{"url":"rtsp://10.0.0.2/live"}}`) - mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) - mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil) - req = withChiURLParam(req, "name", "local_3588_test") - rr := httptest.NewRecorder() + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) + mustSaveVideoSource(t, repo, "line_cam_02", `{"name":"line_cam_02","source_type":"rtsp","area":"西门入口","config":{"url":"rtsp://10.0.0.2/live"}}`) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) + mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) + req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil) + req = withChiURLParam(req, "name", "local_3588_test") + rr := httptest.NewRecorder() - ui.pageAssetProfile(rr, req) + ui.pageAssetProfile(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{ - "场景配置", - "场景名称", - "业务名称", - "调试参数", - "站点名", - "对象存储", - "认证服务", - "告警服务", - "视频通道", - "cam1", - "cam2", - "cam3", - "cam4", - "主视频输入", - "主视频输出", - "新建场景", - "编辑", - "A厂区视觉识别", - "东门入口", - "编辑模式", - "正在编辑场景配置", - `id="active-instance-input"`, - `name="profile_name" value="local_3588_test"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected profile editor page to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"生成预览", "上传为候选配置", "目标设备", "发布入口", "请在设备页中预览并下发", "下发方式", "Profile 编辑页签", "设备编号", `instances[0].device_code`, `instances[0].site_name`} { - if strings.Contains(body, forbidden) { - t.Fatalf("expected profile editor page to omit %q, got:\n%s", forbidden, body) - } - } - for _, forbidden := range []string{"队列大小", "队列策略"} { - if strings.Contains(body, forbidden) { - t.Fatalf("expected profile editor page to omit %q, got:\n%s", forbidden, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "场景配置", + "场景名称", + "业务名称", + "调试参数", + "站点名", + "对象存储", + "认证服务", + "告警服务", + "视频通道", + "cam1", + "cam2", + "cam3", + "cam4", + "主视频输入", + "主视频输出", + "新建场景", + "编辑", + "A厂区视觉识别", + "东门入口", + "编辑模式", + "正在编辑场景配置", + `id="active-instance-input"`, + `name="profile_name" value="local_3588_test"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected profile editor page to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"生成预览", "上传为候选配置", "目标设备", "发布入口", "请在设备页中预览并下发", "下发方式", "Profile 编辑页签", "设备编号", `instances[0].device_code`, `instances[0].site_name`} { + if strings.Contains(body, forbidden) { + t.Fatalf("expected profile editor page to omit %q, got:\n%s", forbidden, body) + } + } + for _, forbidden := range []string{"队列大小", "队列策略"} { + if strings.Contains(body, forbidden) { + t.Fatalf("expected profile editor page to omit %q, got:\n%s", forbidden, body) + } + } } func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) { - t.Skip("legacy scene-config editor replaced by scene-template and recognition-unit pages") - root := createProfileEditorMediaRepo(t) - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.2/live"}}`) - mustSaveVideoSource(t, repo, "line_cam_02", `{"name":"line_cam_02","source_type":"rtsp","area":"西门入口","config":{"url":"rtsp://10.0.0.3/live"}}`) - mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) - mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + t.Skip("legacy scene-config editor replaced by scene-template and recognition-unit pages") + root := createProfileEditorMediaRepo(t) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.2/live"}}`) + mustSaveVideoSource(t, repo, "line_cam_02", `{"name":"line_cam_02","source_type":"rtsp","area":"西门入口","config":{"url":"rtsp://10.0.0.3/live"}}`) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) + mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - form := url.Values{} - form.Set("profile_name", "local_3588_test") - form.Set("business_name", "B厂区视觉识别") - form.Set("description", "updated profile") - form.Set("overlay_name", "face_debug") - form.Set("site_name", "B厂区") - form.Set("instances[0].name", "cam1") - form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm") - form.Set("instances[0].display_name", "西门入口") - form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01") - form.Set("instances[0].service_bindings.object_storage_main.service_ref", "minio_main") - form.Set("instances[0].service_bindings.token_service_main.service_ref", "token_main") - form.Set("instances[0].service_bindings.alarm_service_main.service_ref", "alarm_main") - form.Set("instances[0].output_bindings.stream_output_main.channel_no", "cam1") - form.Set("instances[0].output_bindings.stream_output_main.publish_hls_path", "./web/hls/cam1/index.m3u8") - form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_port", "8556") - form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_path", "/live/cam1") - form.Set("instances[0].advanced_params", `{"queue_debug":true}`) - form.Set("instances[1].name", "cam2") - form.Set("instances[1].template", "std_workshop_face_recognition_shoe_alarm") - form.Set("instances[1].display_name", "视觉识别终端-C厂区") - form.Set("instances[1].input_bindings.video_input_main.video_source_ref", "line_cam_02") - form.Set("instances[1].output_bindings.stream_output_main.channel_no", "cam2") - form.Set("instances[1].output_bindings.stream_output_main.publish_hls_path", "./web/hls/cam2/index.m3u8") - form.Set("instances[1].output_bindings.stream_output_main.publish_rtsp_port", "8557") - form.Set("instances[1].output_bindings.stream_output_main.publish_rtsp_path", "/live/cam2") - form.Set("instances[1].delete", "1") - req := httptest.NewRequest(http.MethodPost, "/ui/assets/profiles/local_3588_test", strings.NewReader(form.Encode())) - req = withChiURLParam(req, "name", "local_3588_test") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("profile_name", "local_3588_test") + form.Set("business_name", "B厂区视觉识别") + form.Set("description", "updated profile") + form.Set("overlay_name", "face_debug") + form.Set("site_name", "B厂区") + form.Set("instances[0].name", "cam1") + form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm") + form.Set("instances[0].display_name", "西门入口") + form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01") + form.Set("instances[0].service_bindings.object_storage_main.service_ref", "minio_main") + form.Set("instances[0].service_bindings.token_service_main.service_ref", "token_main") + form.Set("instances[0].service_bindings.alarm_service_main.service_ref", "alarm_main") + form.Set("instances[0].output_bindings.stream_output_main.channel_no", "cam1") + form.Set("instances[0].output_bindings.stream_output_main.publish_hls_path", "./web/hls/cam1/index.m3u8") + form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_port", "8556") + form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_path", "/live/cam1") + form.Set("instances[0].advanced_params", `{"queue_debug":true}`) + form.Set("instances[1].name", "cam2") + form.Set("instances[1].template", "std_workshop_face_recognition_shoe_alarm") + form.Set("instances[1].display_name", "视觉识别终端-C厂区") + form.Set("instances[1].input_bindings.video_input_main.video_source_ref", "line_cam_02") + form.Set("instances[1].output_bindings.stream_output_main.channel_no", "cam2") + form.Set("instances[1].output_bindings.stream_output_main.publish_hls_path", "./web/hls/cam2/index.m3u8") + form.Set("instances[1].output_bindings.stream_output_main.publish_rtsp_port", "8557") + form.Set("instances[1].output_bindings.stream_output_main.publish_rtsp_path", "/live/cam2") + form.Set("instances[1].delete", "1") + req := httptest.NewRequest(http.MethodPost, "/ui/assets/profiles/local_3588_test", strings.NewReader(form.Encode())) + req = withChiURLParam(req, "name", "local_3588_test") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionAssetProfileSave(rr, req) + ui.actionAssetProfileSave(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/plans?") || !strings.Contains(got, "name=local_3588_test") { - t.Fatalf("expected redirect back to plans, got %q", got) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/plans?") || !strings.Contains(got, "name=local_3588_test") { + t.Fatalf("expected redirect back to plans, got %q", got) + } - saved, err := repo.GetProfile("local_3588_test") - if err != nil { - t.Fatalf("GetProfile: %v", err) - } - if saved == nil { - t.Fatal("expected saved profile in repo") - } - var doc map[string]any - if err := json.Unmarshal([]byte(saved.BodyJSON), &doc); err != nil { - t.Fatalf("unmarshal saved profile: %v", err) - } - if doc["description"] != "updated profile" { - t.Fatalf("unexpected description: %#v", doc) - } - if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" { - t.Fatalf("expected selected overlay to be saved, got %#v", doc["overlays"]) - } - queue, _ := doc["queue"].(map[string]any) - if queue["size"] != float64(8) || queue["strategy"] != "drop_oldest" { - t.Fatalf("expected default queue config, got %#v", queue) - } - instances, _ := doc["instances"].([]any) - if len(instances) != 1 { - t.Fatalf("expected deleted second instance to be omitted, got %#v", instances) - } - instance, _ := instances[0].(map[string]any) - params, _ := instance["params"].(map[string]any) - if doc["business_name"] != "B厂区视觉识别" { - t.Fatalf("expected updated business name, got %#v", doc) - } - inputBindings, _ := instance["input_bindings"].(map[string]any) - videoInput, _ := inputBindings["video_input_main"].(map[string]any) - if videoInput["video_source_ref"] != "gate_cam_01" { - t.Fatalf("expected slot-driven video source ref, got %#v", inputBindings) - } - serviceBindings, _ := instance["service_bindings"].(map[string]any) - objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) - if objectStorage["service_ref"] != "minio_main" { - t.Fatalf("expected object storage binding, got %#v", serviceBindings) - } - outputBindings, _ := instance["output_bindings"].(map[string]any) - streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) - if streamOutput["publish_rtsp_port"] != float64(8556) { - t.Fatalf("expected numeric slot publish_rtsp_port, got %#v", streamOutput["publish_rtsp_port"]) - } - sceneMeta, _ := instance["scene_meta"].(map[string]any) - if sceneMeta["display_name"] != "西门入口" || sceneMeta["site_name"] != "B厂区" { - t.Fatalf("expected scene meta on saved profile, got %#v", sceneMeta) - } - if params["queue_debug"] != true { - t.Fatalf("expected advanced params to survive save, got %#v", params) - } - if _, exists := params["video_source_ref"]; exists { - t.Fatalf("expected new scene document to avoid legacy param duplication, got %#v", params) - } + saved, err := repo.GetProfile("local_3588_test") + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if saved == nil { + t.Fatal("expected saved profile in repo") + } + var doc map[string]any + if err := json.Unmarshal([]byte(saved.BodyJSON), &doc); err != nil { + t.Fatalf("unmarshal saved profile: %v", err) + } + if doc["description"] != "updated profile" { + t.Fatalf("unexpected description: %#v", doc) + } + if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" { + t.Fatalf("expected selected overlay to be saved, got %#v", doc["overlays"]) + } + queue, _ := doc["queue"].(map[string]any) + if queue["size"] != float64(8) || queue["strategy"] != "drop_oldest" { + t.Fatalf("expected default queue config, got %#v", queue) + } + instances, _ := doc["instances"].([]any) + if len(instances) != 1 { + t.Fatalf("expected deleted second instance to be omitted, got %#v", instances) + } + instance, _ := instances[0].(map[string]any) + params, _ := instance["params"].(map[string]any) + if doc["business_name"] != "B厂区视觉识别" { + t.Fatalf("expected updated business name, got %#v", doc) + } + inputBindings, _ := instance["input_bindings"].(map[string]any) + videoInput, _ := inputBindings["video_input_main"].(map[string]any) + if videoInput["video_source_ref"] != "gate_cam_01" { + t.Fatalf("expected slot-driven video source ref, got %#v", inputBindings) + } + serviceBindings, _ := instance["service_bindings"].(map[string]any) + objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any) + if objectStorage["service_ref"] != "minio_main" { + t.Fatalf("expected object storage binding, got %#v", serviceBindings) + } + outputBindings, _ := instance["output_bindings"].(map[string]any) + streamOutput, _ := outputBindings["stream_output_main"].(map[string]any) + if streamOutput["publish_rtsp_port"] != float64(8556) { + t.Fatalf("expected numeric slot publish_rtsp_port, got %#v", streamOutput["publish_rtsp_port"]) + } + sceneMeta, _ := instance["scene_meta"].(map[string]any) + if sceneMeta["display_name"] != "西门入口" || sceneMeta["site_name"] != "B厂区" { + t.Fatalf("expected scene meta on saved profile, got %#v", sceneMeta) + } + if params["queue_debug"] != true { + t.Fatalf("expected advanced params to survive save, got %#v", params) + } + if _, exists := params["video_source_ref"]; exists { + t.Fatalf("expected new scene document to avoid legacy param duplication, got %#v", params) + } } func createBatchConfigMediaRepo(t *testing.T) string { - t.Helper() - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ + t.Helper() + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name":"std_workshop_face_recognition_shoe_alarm", "description":"helmet and shoe alarm", "template":{"nodes":[],"edges":[]} }`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name":"local_3588_test", "business_name":"A厂区视觉识别", "description":"默认班次识别配置", @@ -744,8 +744,8 @@ func createBatchConfigMediaRepo(t *testing.T) string { } ] }`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`) - writeTestFile(t, filepath.Join(root, "tools", "render_config.py"), `import argparse + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`) + writeTestFile(t, filepath.Join(root, "tools", "render_config.py"), `import argparse import json import os @@ -777,13 +777,13 @@ doc = { with open(args.out, "w", encoding="utf-8") as fh: json.dump(doc, fh, ensure_ascii=False, indent=2) `) - return root + return root } func createProfileEditorMediaRepo(t *testing.T) string { - t.Helper() - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ + t.Helper() + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ "name": "std_workshop_face_recognition_shoe_alarm", "slots": { "inputs": [ @@ -800,7 +800,7 @@ func createProfileEditorMediaRepo(t *testing.T) string { }, "template": {"nodes": [], "edges": []} }`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name": "local_3588_test", "description": "test profile", "business_name": "A厂区视觉识别", @@ -842,1746 +842,1746 @@ func createProfileEditorMediaRepo(t *testing.T) string { } ] }`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - return root + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + return root } func withChiURLParam(req *http.Request, key string, value string) *http.Request { - rctx := chi.NewRouteContext() - rctx.URLParams.Add(key, value) - return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add(key, value) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) } func createBatchConfigBrokenMediaRepo(t *testing.T) string { - t.Helper() - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + t.Helper() + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name":"local_3588_test", "business_name":"A厂区视觉识别", "instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}] }`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`) - return root + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`) + return root } func writeTestFile(t *testing.T, path string, body string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) - } - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } } func mustSaveIntegrationService(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) { - t.Helper() - if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil { - t.Fatalf("SaveIntegrationService(%s): %v", name, err) - } + t.Helper() + if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil { + t.Fatalf("SaveIntegrationService(%s): %v", name, err) + } } func mustSaveTemplate(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) { - t.Helper() - if err := repo.SaveTemplate(name, description, body); err != nil { - t.Fatalf("SaveTemplate(%s): %v", name, err) - } + t.Helper() + if err := repo.SaveTemplate(name, description, body); err != nil { + t.Fatalf("SaveTemplate(%s): %v", name, err) + } } func mustImportAssetsForUI(t *testing.T, preview *service.ConfigPreviewService) { - t.Helper() - if _, err := preview.ImportAssetsFromMediaRepo(); err != nil { - t.Fatalf("ImportAssetsFromMediaRepo: %v", err) - } + t.Helper() + if _, err := preview.ImportAssetsFromMediaRepo(); err != nil { + t.Fatalf("ImportAssetsFromMediaRepo: %v", err) + } } func mustSaveVideoSource(t *testing.T, repo *storage.AssetsRepo, name string, body string) { - t.Helper() - if err := repo.SaveVideoSource(name, "rtsp", "", name, body); err != nil { - t.Fatalf("SaveVideoSource(%s): %v", name, err) - } + t.Helper() + if err := repo.SaveVideoSource(name, "rtsp", "", name, body); err != nil { + t.Fatalf("SaveVideoSource(%s): %v", name, err) + } } func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) { - t.Skip("task summary expectations need to be rewritten for device-assignment apply metadata") - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo) - mustImportAssetsForUI(t, ui.preview) + t.Skip("task summary expectations need to be rewritten for device-assignment apply metadata") + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)}, repo) + mustImportAssetsForUI(t, ui.preview) - form := url.Values{} - form.Add("device_id", "edge-01") - form.Add("device_id", "edge-02") - form.Set("template", "std_workshop_face_recognition_shoe_alarm") - form.Set("profile", "local_3588_test") - form.Set("config_id", "batch_edge") - form.Set("config_version", "20260420.090000") - req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Add("device_id", "edge-01") + form.Add("device_id", "edge-02") + form.Set("template", "std_workshop_face_recognition_shoe_alarm") + form.Set("profile", "local_3588_test") + form.Set("config_id", "batch_edge") + form.Set("config_version", "20260420.090000") + req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionDeviceBatchConfig(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } + ui.actionDeviceBatchConfig(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } - loc := rr.Header().Get("Location") - if !strings.HasPrefix(loc, "/ui/tasks/") { - t.Fatalf("expected redirect to task page, got %q", loc) - } + loc := rr.Header().Get("Location") + if !strings.HasPrefix(loc, "/ui/tasks/") { + t.Fatalf("expected redirect to task page, got %q", loc) + } - rrTask := httptest.NewRecorder() - reqTask := httptest.NewRequest(http.MethodGet, loc, nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("id", strings.TrimPrefix(loc, "/ui/tasks/")) - reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx)) - ui.pageTask(rrTask, reqTask) + rrTask := httptest.NewRecorder() + reqTask := httptest.NewRequest(http.MethodGet, loc, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", strings.TrimPrefix(loc, "/ui/tasks/")) + reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx)) + ui.pageTask(rrTask, reqTask) - if rrTask.Code != http.StatusOK { - t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String()) - } - body := rrTask.Body.String() - for _, want := range []string{"任务概览", "返回任务列表", "设备结果", "执行进度", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} { - if !strings.Contains(body, want) { - t.Fatalf("expected task page to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"任务中心", "节点执行情况"} { - if strings.Contains(body, forbidden) { - t.Fatalf("task page should not contain %q, got:\n%s", forbidden, body) - } - } - if strings.Contains(body, "返回操作审计") { - t.Fatalf("task page should not point back to audit, got:\n%s", body) - } - if strings.Index(body, "edge-01") > strings.Index(body, "edge-02") { - t.Fatalf("expected task devices to keep selection order, got:\n%s", body) - } + if rrTask.Code != http.StatusOK { + t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String()) + } + body := rrTask.Body.String() + for _, want := range []string{"任务概览", "返回任务列表", "设备结果", "执行进度", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} { + if !strings.Contains(body, want) { + t.Fatalf("expected task page to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"任务中心", "节点执行情况"} { + if strings.Contains(body, forbidden) { + t.Fatalf("task page should not contain %q, got:\n%s", forbidden, body) + } + } + if strings.Contains(body, "返回操作审计") { + t.Fatalf("task page should not point back to audit, got:\n%s", body) + } + if strings.Index(body, "edge-01") > strings.Index(body, "edge-02") { + t.Fatalf("expected task devices to keep selection order, got:\n%s", body) + } } func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) { - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) + rr := httptest.NewRecorder() - ui.pageDevices(rr, req) + ui.pageDevices(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"设备", "设备总数", "在线设备", "离线设备", "失败操作", "设备列表"} { - if !strings.Contains(body, want) { - t.Fatalf("expected dashboard HTML to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"设备总览", "多台视觉识别设备的统一运维视图", "先看状态,再决定操作。默认只展示值班和维护最需要的信息。"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device page should not contain explanatory copy %q", forbidden) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"设备", "设备总数", "在线设备", "离线设备", "失败操作", "设备列表"} { + if !strings.Contains(body, want) { + t.Fatalf("expected dashboard HTML to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"设备总览", "多台视觉识别设备的统一运维视图", "先看状态,再决定操作。默认只展示值班和维护最需要的信息。"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device page should not contain explanatory copy %q", forbidden) + } + } } func TestUI_DevicePageIncludesFilterAndControlEntry(t *testing.T) { - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) + rr := httptest.NewRecorder() - ui.pageDevices(rr, req) + ui.pageDevices(rr, req) - body := rr.Body.String() - for _, want := range []string{"device-filter", "详情", "控制"} { - if !strings.Contains(body, want) { - t.Fatalf("expected device page HTML to contain %q", want) - } - } - for _, forbidden := range []string{"查看基础配置", "查看操作审计"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device overview should not contain redundant link %q", forbidden) - } - } + body := rr.Body.String() + for _, want := range []string{"device-filter", "详情", "控制"} { + if !strings.Contains(body, want) { + t.Fatalf("expected device page HTML to contain %q", want) + } + } + for _, forbidden := range []string{"查看基础配置", "查看操作审计"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device overview should not contain redundant link %q", forbidden) + } + } } func TestUI_DeviceOverviewShowsLiveStatusAndConfigSummary(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/config/status" { - t.Fatalf("unexpected agent path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "config_path": "/opt/rk3588-media-server/etc/media-server.json", - "exists": true, - "sha256": "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c", - "metadata": { - "config_id": "local_3588_face_debug", - "config_version": "20260419.104457", - "template": "std_workshop_face_recognition_shoe_alarm", - "profile": "local_3588_test", - "overlays": ["face_debug", "production_quiet"] - }, - "media_server": {"running": true, "pid": 1706009} - }`)) - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/config/status" { + t.Fatalf("unexpected agent path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "config_path": "/opt/rk3588-media-server/etc/media-server.json", + "exists": true, + "sha256": "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c", + "metadata": { + "config_id": "local_3588_face_debug", + "config_version": "20260419.104457", + "template": "std_workshop_face_recognition_shoe_alarm", + "profile": "local_3588_test", + "overlays": ["face_debug", "production_quiet"] + }, + "media_server": {"running": true, "pid": 1706009} + }`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: false, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"}) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: false, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) - rr := httptest.NewRecorder() - ui.pageDevices(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) + rr := httptest.NewRecorder() + ui.pageDevices(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"在线", "运行中", "local_3588_face_debug"} { - if !strings.Contains(body, want) { - t.Fatalf("expected device overview HTML to contain %q, got:\n%s", want, body) - } - } - if strings.Contains(body, "待在详情页查看") { - t.Fatalf("device overview should show live config summary instead of placeholder text") - } - for _, forbidden := range []string{"20260419.104457", "face_debug, production_quiet"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device overview should not expose extra config details %q in main list", forbidden) - } - } - if strings.Contains(body, "心跳") { - t.Fatalf("device overview should not show heartbeat text in status cell") - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"在线", "运行中", "local_3588_face_debug"} { + if !strings.Contains(body, want) { + t.Fatalf("expected device overview HTML to contain %q, got:\n%s", want, body) + } + } + if strings.Contains(body, "待在详情页查看") { + t.Fatalf("device overview should show live config summary instead of placeholder text") + } + for _, forbidden := range []string{"20260419.104457", "face_debug, production_quiet"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device overview should not expose extra config details %q in main list", forbidden) + } + } + if strings.Contains(body, "心跳") { + t.Fatalf("device overview should not show heartbeat text in status cell") + } } func TestUI_DeviceOverviewUsesCompactColumns(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/config/status" { - t.Fatalf("unexpected agent path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "metadata": { - "config_id": "local_3588_face_debug", - "config_version": "20260419.104457", - "overlays": ["face_debug"] - }, - "media_server": {"running": true} - }`)) - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/config/status" { + t.Fatalf("unexpected agent path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "metadata": { + "config_id": "local_3588_face_debug", + "config_version": "20260419.104457", + "overlays": ["face_debug"] + }, + "media_server": {"running": true} + }`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "d12a4719c91641df91b76ab271280797", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "0.1.0", BuildID: "20260419.151628", GitSha: "5c04681"}) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "d12a4719c91641df91b76ab271280797", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "0.1.0", BuildID: "20260419.151628", GitSha: "5c04681"}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) - rr := httptest.NewRecorder() - ui.pageDevices(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) + rr := httptest.NewRecorder() + ui.pageDevices(rr, req) - body := rr.Body.String() - for _, want := range []string{"设备", "状态", "当前配置", "操作", "orangepi5plus", "0.1.0", "#2026041"} { - if !strings.Contains(body, want) { - t.Fatalf("expected compact overview HTML to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"#5c04681", "Git SHA"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device overview should not expose git sha marker %q", forbidden) - } - } - for _, forbidden := range []string{"最后心跳", "版本", "在线", "服务"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device overview should not keep verbose column header %q", forbidden) - } - } - if strings.Contains(body, "心跳") { - t.Fatalf("device overview compact view should not render heartbeat text") - } + body := rr.Body.String() + for _, want := range []string{"设备", "状态", "当前配置", "操作", "orangepi5plus", "0.1.0", "#2026041"} { + if !strings.Contains(body, want) { + t.Fatalf("expected compact overview HTML to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"#5c04681", "Git SHA"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device overview should not expose git sha marker %q", forbidden) + } + } + for _, forbidden := range []string{"最后心跳", "版本", "在线", "服务"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device overview should not keep verbose column header %q", forbidden) + } + } + if strings.Contains(body, "心跳") { + t.Fatalf("device overview compact view should not render heartbeat text") + } } func TestUI_DeviceDetailIncludesWorkspaceSections(t *testing.T) { - ui := newTestUI(t) - routes, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - rr := httptest.NewRecorder() + ui := newTestUI(t) + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() - routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) - body := rr.Body.String() - for _, want := range []string{"设备详情", "当前设备", "设备工作台", "概览", "运行与服务", "设备分配"} { - if !strings.Contains(body, want) { - t.Fatalf("expected device detail HTML to contain %q", want) - } - } - for _, forbidden := range []string{"device-tabs", "返回设备总览", "进入管理"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device detail should not contain redundant nav %q", forbidden) - } - } + body := rr.Body.String() + for _, want := range []string{"设备详情", "当前设备", "设备工作台", "概览", "运行与服务", "设备分配"} { + if !strings.Contains(body, want) { + t.Fatalf("expected device detail HTML to contain %q", want) + } + } + for _, forbidden := range []string{"device-tabs", "返回设备总览", "进入管理"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device detail should not contain redundant nav %q", forbidden) + } + } } func TestUI_DeviceDetailUsesUnifiedWorkspace(t *testing.T) { - ui := newTestUI(t) - routes, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - rr := httptest.NewRecorder() + ui := newTestUI(t) + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() - routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) - body := rr.Body.String() - for _, want := range []string{"设备详情", "设备工作台", "运行与服务", "设备分配", "模型与资源", "日志与指标"} { - if !strings.Contains(body, want) { - t.Fatalf("expected unified device workspace HTML to contain %q", want) - } - } - for _, forbidden := range []string{"只读查看页", "权威摘要位置", "当前框架版"} { - if strings.Contains(body, forbidden) { - t.Fatalf("device detail should not contain placeholder copy %q", forbidden) - } - } - for _, forbidden := range []string{ - `action="/ui/devices/edge-01/alias"`, - } { - if strings.Contains(body, forbidden) { - t.Fatalf("device detail workspace should not contain obsolete entry %q", forbidden) - } - } + body := rr.Body.String() + for _, want := range []string{"设备详情", "设备工作台", "运行与服务", "设备分配", "模型与资源", "日志与指标"} { + if !strings.Contains(body, want) { + t.Fatalf("expected unified device workspace HTML to contain %q", want) + } + } + for _, forbidden := range []string{"只读查看页", "权威摘要位置", "当前框架版"} { + if strings.Contains(body, forbidden) { + t.Fatalf("device detail should not contain placeholder copy %q", forbidden) + } + } + for _, forbidden := range []string{ + `action="/ui/devices/edge-01/alias"`, + } { + if strings.Contains(body, forbidden) { + t.Fatalf("device detail workspace should not contain obsolete entry %q", forbidden) + } + } } func TestUI_DeviceDetailShowsRunningConfigMetadata(t *testing.T) { - t.Skip("device detail expectations need rewrite for device-assignment terminology") - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/config/status" { - t.Fatalf("unexpected agent path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "config_path": "/opt/rk3588-media-server/etc/media-server.json", - "exists": true, - "sha256": "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c", - "metadata": { - "config_id": "local_3588_face_debug", - "config_version": "20260419.104457", - "template": "std_workshop_face_recognition_shoe_alarm", - "profile": "local_3588_test", - "overlays": ["face_debug"], - "rendered_by": "tools/render_config.py", - "rendered_at": "2026-04-19T10:44:58+08:00" - }, - "media_server": {"running": true, "pid": 1706009} - }`)) - })) - defer agentServer.Close() + t.Skip("device detail expectations need rewrite for device-assignment terminology") + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/config/status" { + t.Fatalf("unexpected agent path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "config_path": "/opt/rk3588-media-server/etc/media-server.json", + "exists": true, + "sha256": "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c", + "metadata": { + "config_id": "local_3588_face_debug", + "config_version": "20260419.104457", + "template": "std_workshop_face_recognition_shoe_alarm", + "profile": "local_3588_test", + "overlays": ["face_debug"], + "rendered_by": "tools/render_config.py", + "rendered_at": "2026-04-19T10:44:58+08:00" + }, + "media_server": {"running": true, "pid": 1706009} + }`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"}) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } - routes, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - rr := httptest.NewRecorder() + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() - routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{ - "当前运行配置", - "local_3588_face_debug", - "20260419.104457", - "std_workshop_face_recognition_shoe_alarm", - "local_3588_test", - "face_debug", - "8d935c9d", - "/opt/rk3588-media-server/etc/media-server.json", - "运行中", - "下发设备分配", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected device detail HTML to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"当前运行场景", "当前模板", "最近下发任务"} { - if strings.Contains(body, forbidden) { - t.Fatalf("expected scene config panel to avoid duplicate runtime text %q, got:\n%s", forbidden, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "当前运行配置", + "local_3588_face_debug", + "20260419.104457", + "std_workshop_face_recognition_shoe_alarm", + "local_3588_test", + "face_debug", + "8d935c9d", + "/opt/rk3588-media-server/etc/media-server.json", + "运行中", + "下发设备分配", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected device detail HTML to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"当前运行场景", "当前模板", "最近下发任务"} { + if strings.Contains(body, forbidden) { + t.Fatalf("expected scene config panel to avoid duplicate runtime text %q, got:\n%s", forbidden, body) + } + } } func TestUI_DeviceDetailDoesNotUseChannelDisplayNameAsDeviceName(t *testing.T) { - t.Skip("device detail expectations need rewrite for device-assignment terminology") - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/config/status" { - t.Fatalf("unexpected agent path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "metadata": { - "business_name": "A厂区视觉识别", - "instance_name": "cam1", - "instance_names": ["cam1"], - "instance_display_names": ["东门入口"] - }, - "media_server": {"running": true} - }`)) - })) - defer agentServer.Close() + t.Skip("device detail expectations need rewrite for device-assignment terminology") + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/config/status" { + t.Fatalf("unexpected agent path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "metadata": { + "business_name": "A厂区视觉识别", + "instance_name": "cam1", + "instance_names": ["cam1"], + "instance_display_names": ["东门入口"] + }, + "media_server": {"running": true} + }`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - reg := service.NewRegistryService(cfg, agent, storage.NewDevicesRepo(store.DB())) - if err := reg.SetDeviceAlias("edge-01", "备用盒子-01"); err != nil { - t.Fatalf("SetDeviceAlias: %v", err) - } - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"}) - ui, err := NewUI(nil, reg, agent, service.NewTaskService(cfg, agent, reg), nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } - routes, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - rr := httptest.NewRecorder() + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + reg := service.NewRegistryService(cfg, agent, storage.NewDevicesRepo(store.DB())) + if err := reg.SetDeviceAlias("edge-01", "备用盒子-01"); err != nil { + t.Fatalf("SetDeviceAlias: %v", err) + } + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "1.0.0"}) + ui, err := NewUI(nil, reg, agent, service.NewTaskService(cfg, agent, reg), nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() - routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil)) - body := rr.Body.String() - for _, want := range []string{"备用盒子-01", "当前业务配置", "A厂区视觉识别", "通道名", "cam1"} { - if !strings.Contains(body, want) { - t.Fatalf("expected device detail to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{ - "设备名称东门入口", - "当前业务配置东门入口", - } { - if strings.Contains(body, forbidden) { - t.Fatalf("channel display name should not be used as device or business name, got:\n%s", body) - } - } + body := rr.Body.String() + for _, want := range []string{"备用盒子-01", "当前业务配置", "A厂区视觉识别", "通道名", "cam1"} { + if !strings.Contains(body, want) { + t.Fatalf("expected device detail to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{ + "设备名称东门入口", + "当前业务配置东门入口", + } { + if strings.Contains(body, forbidden) { + t.Fatalf("channel display name should not be used as device or business name, got:\n%s", body) + } + } } func TestUI_DeviceSubpagesIncludeContextNavigation(t *testing.T) { - ui := newTestUI(t) - dev := &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true} - for _, content := range []string{"device_logs", "device_graphs", "config_ui"} { - req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01", nil) - rr := httptest.NewRecorder() - ui.render(rr, req, content, PageData{Title: "节点子页面", Device: dev}) - body := rr.Body.String() - for _, want := range []string{"当前设备"} { - if !strings.Contains(body, want) { - t.Fatalf("expected %s HTML to contain %q", content, want) - } - } - if strings.Contains(body, "device-tabs") { - t.Fatalf("expected %s HTML to avoid device cross-page tabs", content) - } - } + ui := newTestUI(t) + dev := &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true} + for _, content := range []string{"device_logs", "device_graphs", "config_ui"} { + req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01", nil) + rr := httptest.NewRecorder() + ui.render(rr, req, content, PageData{Title: "节点子页面", Device: dev}) + body := rr.Body.String() + for _, want := range []string{"当前设备"} { + if !strings.Contains(body, want) { + t.Fatalf("expected %s HTML to contain %q", content, want) + } + } + if strings.Contains(body, "device-tabs") { + t.Fatalf("expected %s HTML to avoid device cross-page tabs", content) + } + } } func TestUI_ConfigFriendlyIncludesWizard(t *testing.T) { - ui := newTestUI(t) - routes, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - rr := httptest.NewRecorder() + ui := newTestUI(t) + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() - routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/config-friendly", nil)) + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/config-friendly", nil)) - body := rr.Body.String() - for _, want := range []string{"config-wizard", "选择节点", "编辑视频通道", "预览变更", "部署结果", "data-wizard-section=\"channels\"", "下一步:编辑视频通道", "前往预览变更", "查看部署结果"} { - if !strings.Contains(body, want) { - t.Fatalf("expected config page HTML to contain %q", want) - } - } + body := rr.Body.String() + for _, want := range []string{"config-wizard", "选择节点", "编辑视频通道", "预览变更", "部署结果", "data-wizard-section=\"channels\"", "下一步:编辑视频通道", "前往预览变更", "查看部署结果"} { + if !strings.Contains(body, want) { + t.Fatalf("expected config page HTML to contain %q", want) + } + } } func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) { - t.Skip("legacy config-preview form replaced by device-assignment preview") - ui := newTestUI(t) - routes, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - rr := httptest.NewRecorder() + t.Skip("legacy config-preview form replaced by device-assignment preview") + ui := newTestUI(t) + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() - routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/config-preview", nil)) + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/config-preview", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{ - "配置预览", - "模板", - "场景配置", - "调试参数", - "config_id", - "config_version", - "生成预览", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected config preview page to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"返回节点详情", "完整 JSON"} { - if strings.Contains(body, forbidden) { - t.Fatalf("config preview page should not contain redundant element %q", forbidden) - } - } - for _, forbidden := range []string{"上传为候选配置", "应用候选配置"} { - if strings.Contains(body, forbidden) { - t.Fatalf("config preview page should not show action %q before preview is generated", forbidden) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "配置预览", + "模板", + "场景配置", + "调试参数", + "config_id", + "config_version", + "生成预览", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected config preview page to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"返回节点详情", "完整 JSON"} { + if strings.Contains(body, forbidden) { + t.Fatalf("config preview page should not contain redundant element %q", forbidden) + } + } + for _, forbidden := range []string{"上传为候选配置", "应用候选配置"} { + if strings.Contains(body, forbidden) { + t.Fatalf("config preview page should not show action %q before preview is generated", forbidden) + } + } } func TestUI_ConfigPreviewPageKeepsApplyActionAfterUploadResult(t *testing.T) { - t.Skip("legacy candidate-config preview flow removed from migrated device-assignment preview page") - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) - rr := httptest.NewRecorder() + t.Skip("legacy candidate-config preview flow removed from migrated device-assignment preview page") + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) + rr := httptest.NewRecorder() - ui.render(rr, req, "config_preview", PageData{ - Title: "配置预览", - Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, - ConfigStatus: &ConfigStatusView{ - Candidate: &ConfigStatusLastGoodFile{Exists: true, Path: "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, - }, - ConfigPreview: &service.ConfigPreviewResult{ - JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1"}}`, - Metadata: map[string]any{ - "config_id": "preview_edge-01", - "config_version": "v1", - "template": "std_workshop_face_recognition_shoe_alarm", - "profile": "local_3588_test", - }, - Size: 123, - }, - RawText: `{"ok":true}`, - ResultTitle: "应用候选配置结果", - }) + ui.render(rr, req, "config_preview", PageData{ + Title: "配置预览", + Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, + ConfigStatus: &ConfigStatusView{ + Candidate: &ConfigStatusLastGoodFile{Exists: true, Path: "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, + }, + ConfigPreview: &service.ConfigPreviewResult{ + JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1"}}`, + Metadata: map[string]any{ + "config_id": "preview_edge-01", + "config_version": "v1", + "template": "std_workshop_face_recognition_shoe_alarm", + "profile": "local_3588_test", + }, + Size: 123, + }, + RawText: `{"ok":true}`, + ResultTitle: "应用候选配置结果", + }) - body := rr.Body.String() - for _, want := range []string{ - "应用候选配置结果", - "上传为候选配置", - "应用候选配置", - `formaction="/ui/devices/edge-01/config-candidate/apply"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected config preview upload result HTML to contain %q, got:\n%s", want, body) - } - } - if strings.Contains(body, "

执行结果

") { - t.Fatalf("config preview page should not render duplicate result card, got:\n%s", body) - } + body := rr.Body.String() + for _, want := range []string{ + "应用候选配置结果", + "上传为候选配置", + "应用候选配置", + `formaction="/ui/devices/edge-01/config-candidate/apply"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected config preview upload result HTML to contain %q, got:\n%s", want, body) + } + } + if strings.Contains(body, "

执行结果

") { + t.Fatalf("config preview page should not render duplicate result card, got:\n%s", body) + } } func TestUI_ActionDeviceConfigCandidateKeepsPreviewApplyAction(t *testing.T) { - t.Skip("legacy candidate-config flow is no longer exposed in migrated UI") - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/v1/config/status" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"candidate":{"exists":true,"path":"/opt/rk3588-media-server/etc/media-server.json.candidate.json"}}`)) - return - } - if r.Method != http.MethodPut || r.URL.Path != "/v1/config/candidate" { - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer agentServer.Close() + t.Skip("legacy candidate-config flow is no longer exposed in migrated UI") + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v1/config/status" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"candidate":{"exists":true,"path":"/opt/rk3588-media-server/etc/media-server.json.candidate.json"}}`)) + return + } + if r.Method != http.MethodPut || r.URL.Path != "/v1/config/candidate" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - form := url.Values{} - form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test"}}`) - req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetPathValue("id", "edge-01") - rctx := chi.NewRouteContext() - rctx.URLParams.Add("id", "edge-01") - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test"}}`) + req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetPathValue("id", "edge-01") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "edge-01") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() - ui.actionDeviceConfigCandidate(rr, req) + ui.actionDeviceConfigCandidate(rr, req) - body := rr.Body.String() - for _, want := range []string{ - "候选配置结果", - "应用候选配置", - `formaction="/ui/devices/edge-01/config-candidate/apply"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected actionDeviceConfigCandidate HTML to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{ + "候选配置结果", + "应用候选配置", + `formaction="/ui/devices/edge-01/config-candidate/apply"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected actionDeviceConfigCandidate HTML to contain %q, got:\n%s", want, body) + } + } } func TestUI_ConfigPreviewKeepsSelectedOverlayAfterPreview(t *testing.T) { - t.Skip("legacy config-preview form replaced by device-assignment preview") - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) - rr := httptest.NewRecorder() + t.Skip("legacy config-preview form replaced by device-assignment preview") + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) + rr := httptest.NewRecorder() - ui.render(rr, req, "config_preview", PageData{ - Title: "配置预览", - Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, - ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}, {Name: "face_test_sensitive"}}}, - SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", - SelectedProfile: "local_3588_test", - SelectedOverlays: []string{"face_test_sensitive"}, - SelectedConfigID: "preview_edge-01", - ConfigPreview: &service.ConfigPreviewResult{ - JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test","overlays":["face_test_sensitive"]}}`, - Metadata: map[string]any{ - "config_id": "preview_edge-01", - "config_version": "v1", - "template": "std_workshop_face_recognition_shoe_alarm", - "profile": "local_3588_test", - "overlays": []any{"face_test_sensitive"}, - }, - Size: 123, - }, - }) + ui.render(rr, req, "config_preview", PageData{ + Title: "配置预览", + Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, + ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}, {Name: "face_test_sensitive"}}}, + SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", + SelectedProfile: "local_3588_test", + SelectedOverlays: []string{"face_test_sensitive"}, + SelectedConfigID: "preview_edge-01", + ConfigPreview: &service.ConfigPreviewResult{ + JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test","overlays":["face_test_sensitive"]}}`, + Metadata: map[string]any{ + "config_id": "preview_edge-01", + "config_version": "v1", + "template": "std_workshop_face_recognition_shoe_alarm", + "profile": "local_3588_test", + "overlays": []any{"face_test_sensitive"}, + }, + Size: 123, + }, + }) - body := rr.Body.String() - if !strings.Contains(body, `value="face_test_sensitive" checked`) { - t.Fatalf("expected selected overlay to remain checked, got:\n%s", body) - } - if strings.Contains(body, `value="face_debug" checked`) { - t.Fatalf("did not expect default overlay to stay checked, got:\n%s", body) - } + body := rr.Body.String() + if !strings.Contains(body, `value="face_test_sensitive" checked`) { + t.Fatalf("expected selected overlay to remain checked, got:\n%s", body) + } + if strings.Contains(body, `value="face_debug" checked`) { + t.Fatalf("did not expect default overlay to stay checked, got:\n%s", body) + } } func TestUI_ConfigPreviewCollapsesJSONAfterActionResult(t *testing.T) { - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) + rr := httptest.NewRecorder() - ui.render(rr, req, "config_preview", PageData{ - Title: "配置预览", - Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, - ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}}}, - SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", - SelectedProfile: "local_3588_test", - SelectedOverlays: []string{"face_debug"}, - SelectedConfigID: "preview_edge-01", - ConfigPreview: &service.ConfigPreviewResult{ - JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[]}`, - Metadata: map[string]any{"config_id": "preview_edge-01"}, - Size: 64, - }, - ResultTitle: "候选配置结果", - RawText: `{"ok":true}`, - }) + ui.render(rr, req, "config_preview", PageData{ + Title: "配置预览", + Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, + ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}}}, + SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", + SelectedProfile: "local_3588_test", + SelectedOverlays: []string{"face_debug"}, + SelectedConfigID: "preview_edge-01", + ConfigPreview: &service.ConfigPreviewResult{ + JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[]}`, + Metadata: map[string]any{"config_id": "preview_edge-01"}, + Size: 64, + }, + ResultTitle: "候选配置结果", + RawText: `{"ok":true}`, + }) - body := rr.Body.String() - if !strings.Contains(body, "") { - t.Fatalf("expected json panel to be collapsed after action result, got:\n%s", body) - } + body := rr.Body.String() + if !strings.Contains(body, "") { + t.Fatalf("expected json panel to be collapsed after action result, got:\n%s", body) + } } func TestUI_ConfigPreviewShowsApplySummaryAfterApplyResult(t *testing.T) { - t.Skip("legacy candidate-config apply summary removed from migrated device-assignment preview page") - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) - rr := httptest.NewRecorder() + t.Skip("legacy candidate-config apply summary removed from migrated device-assignment preview page") + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) + rr := httptest.NewRecorder() - ui.render(rr, req, "config_preview", PageData{ - Title: "配置预览", - Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, - SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", - SelectedProfile: "local_3588_test", - SelectedOverlays: []string{"face_debug"}, - SelectedConfigID: "preview_edge-01", - ConfigPreview: &service.ConfigPreviewResult{ - JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2"}}`, - Metadata: map[string]any{ - "config_id": "preview_edge-01", - "config_version": "v2", - "overlays": []any{"face_test_sensitive", "production_quiet"}, - }, - Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5", - Size: 64, - }, - ConfigStatus: &ConfigStatusView{ - OK: true, - Metadata: ConfigStatusMetadata{ - ConfigID: "preview_edge-01", - ConfigVersion: "v2", - Overlays: []string{"face_test_sensitive", "production_quiet"}, - }, - Candidate: &ConfigStatusLastGoodFile{ - Exists: false, - Path: "/opt/rk3588-media-server/etc/media-server.json.candidate.json", - }, - PreviousConfig: &ConfigStatusLastGoodFile{ - Exists: true, - Path: "/opt/rk3588-media-server/etc/media-server.json.last_good.json", - Sha256: "07a37fabd73e98c1c131d31ddfa7b6c0e0949be854225bf2a6e990a09e60ddd3", - Metadata: ConfigStatusMetadata{ - ConfigID: "local_3588_face_debug", - ConfigVersion: "20260419.120246", - Overlays: []string{"face_debug"}, - }, - }, - MediaServer: ConfigStatusMediaServer{ - Running: true, - PID: 1810489, - }, - }, - ResultTitle: "应用候选配置结果", - RawText: `{"ok":true}`, - }) + ui.render(rr, req, "config_preview", PageData{ + Title: "配置预览", + Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, + SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", + SelectedProfile: "local_3588_test", + SelectedOverlays: []string{"face_debug"}, + SelectedConfigID: "preview_edge-01", + ConfigPreview: &service.ConfigPreviewResult{ + JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2"}}`, + Metadata: map[string]any{ + "config_id": "preview_edge-01", + "config_version": "v2", + "overlays": []any{"face_test_sensitive", "production_quiet"}, + }, + Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5", + Size: 64, + }, + ConfigStatus: &ConfigStatusView{ + OK: true, + Metadata: ConfigStatusMetadata{ + ConfigID: "preview_edge-01", + ConfigVersion: "v2", + Overlays: []string{"face_test_sensitive", "production_quiet"}, + }, + Candidate: &ConfigStatusLastGoodFile{ + Exists: false, + Path: "/opt/rk3588-media-server/etc/media-server.json.candidate.json", + }, + PreviousConfig: &ConfigStatusLastGoodFile{ + Exists: true, + Path: "/opt/rk3588-media-server/etc/media-server.json.last_good.json", + Sha256: "07a37fabd73e98c1c131d31ddfa7b6c0e0949be854225bf2a6e990a09e60ddd3", + Metadata: ConfigStatusMetadata{ + ConfigID: "local_3588_face_debug", + ConfigVersion: "20260419.120246", + Overlays: []string{"face_debug"}, + }, + }, + MediaServer: ConfigStatusMediaServer{ + Running: true, + PID: 1810489, + }, + }, + ResultTitle: "应用候选配置结果", + RawText: `{"ok":true}`, + }) - body := rr.Body.String() - for _, want := range []string{ - "应用结果摘要", - "当前运行", - "preview_edge-01 / v2", - "上一份配置", - "local_3588_face_debug / 20260419.120246", - "face_test_sensitive, production_quiet", - "face_debug", - "eecdf8d4", - "07a37fab", - "候选配置", - "已清空", - "视觉服务", - "运行中", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected apply summary HTML to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{ + "应用结果摘要", + "当前运行", + "preview_edge-01 / v2", + "上一份配置", + "local_3588_face_debug / 20260419.120246", + "face_test_sensitive, production_quiet", + "face_debug", + "eecdf8d4", + "07a37fab", + "候选配置", + "已清空", + "视觉服务", + "运行中", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected apply summary HTML to contain %q, got:\n%s", want, body) + } + } } func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) { - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices/edge-01/config-preview", nil) + rr := httptest.NewRecorder() - ui.render(rr, req, "config_preview", PageData{ - Title: "配置预览", - Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, - SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", - SelectedProfile: "local_3588_test", - SelectedOverlays: []string{"face_debug"}, - SelectedConfigID: "preview_edge-01", - ConfigPreview: &service.ConfigPreviewResult{ - JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","overlays":["face_debug"]}}`, - Metadata: map[string]any{ - "config_id": "preview_edge-01", - "config_version": "v2", - "template": "std_workshop_face_recognition_shoe_alarm", - "profile": "local_3588_test", - "overlays": []any{"face_debug"}, - }, - Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5", - Size: 64, - }, - }) + ui.render(rr, req, "config_preview", PageData{ + Title: "配置预览", + Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100}, + SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", + SelectedProfile: "local_3588_test", + SelectedOverlays: []string{"face_debug"}, + SelectedConfigID: "preview_edge-01", + ConfigPreview: &service.ConfigPreviewResult{ + JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","overlays":["face_debug"]}}`, + Metadata: map[string]any{ + "config_id": "preview_edge-01", + "config_version": "v2", + "template": "std_workshop_face_recognition_shoe_alarm", + "profile": "local_3588_test", + "overlays": []any{"face_debug"}, + }, + Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5", + Size: 64, + }, + }) - body := rr.Body.String() - for _, want := range []string{ - "config_id", - "config_version", - "SHA256", - "调试参数", - "face_debug", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected config preview HTML to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{ - "是配置名,默认会带上", - "表示本次生成版本", - "是最终文件内容指纹", - } { - if strings.Contains(body, forbidden) { - t.Fatalf("config preview should not contain explanatory copy %q", forbidden) - } - } + body := rr.Body.String() + for _, want := range []string{ + "config_id", + "config_version", + "SHA256", + "调试参数", + "face_debug", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected config preview HTML to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{ + "是配置名,默认会带上", + "表示本次生成版本", + "是最终文件内容指纹", + } { + if strings.Contains(body, forbidden) { + t.Fatalf("config preview should not contain explanatory copy %q", forbidden) + } + } } func TestUI_ActionDeviceConfigCandidateApplyReloadsStatusAfterApply(t *testing.T) { - t.Skip("legacy candidate-config apply flow is no longer exposed in migrated UI") - statusCalls := 0 - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status": - statusCalls++ - w.Header().Set("Content-Type", "application/json") - if statusCalls == 1 { - _, _ = w.Write([]byte(`{ - "ok": true, - "metadata": {"config_id": "preview_edge-01", "config_version": "v2"}, - "candidate": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, - "previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}}, - "media_server": {"running": true, "pid": 1810489} - }`)) - return - } - _, _ = w.Write([]byte(`{ - "ok": true, - "metadata": {"config_id": "preview_edge-01", "config_version": "v2"}, - "candidate": {"exists": false, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, - "previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}}, - "media_server": {"running": true, "pid": 1810489} - }`)) - return - case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - return - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer agentServer.Close() + t.Skip("legacy candidate-config apply flow is no longer exposed in migrated UI") + statusCalls := 0 + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status": + statusCalls++ + w.Header().Set("Content-Type", "application/json") + if statusCalls == 1 { + _, _ = w.Write([]byte(`{ + "ok": true, + "metadata": {"config_id": "preview_edge-01", "config_version": "v2"}, + "candidate": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, + "previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}}, + "media_server": {"running": true, "pid": 1810489} + }`)) + return + } + _, _ = w.Write([]byte(`{ + "ok": true, + "metadata": {"config_id": "preview_edge-01", "config_version": "v2"}, + "candidate": {"exists": false, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, + "previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}}, + "media_server": {"running": true, "pid": 1810489} + }`)) + return + case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + return + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - form := url.Values{} - form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test"}}`) - req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetPathValue("id", "edge-01") - rctx := chi.NewRouteContext() - rctx.URLParams.Add("id", "edge-01") - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test"}}`) + req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetPathValue("id", "edge-01") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "edge-01") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() - ui.actionDeviceConfigCandidateApply(rr, req) + ui.actionDeviceConfigCandidateApply(rr, req) - body := rr.Body.String() - if statusCalls != 2 { - t.Fatalf("expected status to be loaded twice, got %d", statusCalls) - } - for _, want := range []string{ - "应用结果摘要", - "preview_edge-01 / v2", - "已清空", - "运行中", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected apply result HTML to contain %q, got:\n%s", want, body) - } - } - if strings.Contains(body, "仍存在") { - t.Fatalf("expected refreshed status after apply, got stale candidate state:\n%s", body) - } + body := rr.Body.String() + if statusCalls != 2 { + t.Fatalf("expected status to be loaded twice, got %d", statusCalls) + } + for _, want := range []string{ + "应用结果摘要", + "preview_edge-01 / v2", + "已清空", + "运行中", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected apply result HTML to contain %q, got:\n%s", want, body) + } + } + if strings.Contains(body, "仍存在") { + t.Fatalf("expected refreshed status after apply, got stale candidate state:\n%s", body) + } } func TestUI_LoadConfigStatusPrefersPreviousConfigFields(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/v1/config/status" { - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "metadata": {"config_id": "preview-edge", "config_version": "v2"}, - "previous_config_path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", - "previous_config": { - "exists": true, - "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", - "sha256": "abc12345", - "metadata": {"config_id": "prev-edge", "config_version": "v1", "overlays": ["face_debug"]} - }, - "media_server": {"running": true, "pid": 1234} - }`)) - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/config/status" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "metadata": {"config_id": "preview-edge", "config_version": "v2"}, + "previous_config_path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", + "previous_config": { + "exists": true, + "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", + "sha256": "abc12345", + "metadata": {"config_id": "prev-edge", "config_version": "v1", "overlays": ["face_debug"]} + }, + "media_server": {"running": true, "pid": 1234} + }`)) + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - status, _, err := ui.loadConfigStatus(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) - if err != nil { - t.Fatalf("loadConfigStatus: %v", err) - } - if status == nil || status.PreviousConfig == nil { - t.Fatalf("expected previous config to be present, got %#v", status) - } - if status.PreviousConfig.Metadata.ConfigID != "prev-edge" { - t.Fatalf("previous config metadata = %#v", status.PreviousConfig.Metadata) - } + status, _, err := ui.loadConfigStatus(&models.Device{DeviceID: "edge-01", IP: host, AgentPort: port}) + if err != nil { + t.Fatalf("loadConfigStatus: %v", err) + } + if status == nil || status.PreviousConfig == nil { + t.Fatalf("expected previous config to be present, got %#v", status) + } + if status.PreviousConfig.Metadata.ConfigID != "prev-edge" { + t.Fatalf("previous config metadata = %#v", status.PreviousConfig.Metadata) + } } func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/models/status": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123},{"name":"best-640","file_name":"best-640.rknn","sha256":"sha-x","size_bytes":456}]}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(server.Listener.Addr().String(), "[::]")) - if err != nil { - t.Fatalf("SplitHostPort: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("Atoi: %v", err) - } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - dbPath := filepath.Join(t.TempDir(), "models.db") - store, err := storage.OpenSQLite(dbPath) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) - agent := service.NewAgentClient(cfg) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } - modelsRepo := storage.NewModelsRepo(store.DB()) - if err := modelsRepo.Save(storage.StandardModelRecord{ - Name: "face_det_scrfd_500m_640_rk3588", - FileName: "face_det_scrfd_500m_640_rk3588.rknn", - Version: "v1", - SHA256: "sha-1", - SizeBytes: 123, - ModelType: "face_detection", - }); err != nil { - t.Fatalf("Save: %v", err) - } - ui.SetDBPath(dbPath) - req := httptest.NewRequest(http.MethodGet, "/ui/models", nil) - rr := httptest.NewRecorder() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/models/status": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123},{"name":"best-640","file_name":"best-640.rknn","sha256":"sha-x","size_bytes":456}]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + host, portText, err := net.SplitHostPort(strings.TrimPrefix(server.Listener.Addr().String(), "[::]")) + if err != nil { + t.Fatalf("SplitHostPort: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("Atoi: %v", err) + } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + dbPath := filepath.Join(t.TempDir(), "models.db") + store, err := storage.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) + agent := service.NewAgentClient(cfg) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + modelsRepo := storage.NewModelsRepo(store.DB()) + if err := modelsRepo.Save(storage.StandardModelRecord{ + Name: "face_det_scrfd_500m_640_rk3588", + FileName: "face_det_scrfd_500m_640_rk3588.rknn", + Version: "v1", + SHA256: "sha-1", + SizeBytes: 123, + ModelType: "face_detection", + }); err != nil { + t.Fatalf("Save: %v", err) + } + ui.SetDBPath(dbPath) + req := httptest.NewRequest(http.MethodGet, "/ui/models", nil) + rr := httptest.NewRecorder() - ui.pageModels(rr, req) + ui.pageModels(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型", "非标准模型", "人脸检测"} { - if !strings.Contains(body, want) { - t.Fatalf("expected model management HTML to contain %q", want) - } - } - for _, want := range []string{"1 个 · 更多", "best-640.rknn"} { - if !strings.Contains(body, want) { - t.Fatalf("expected model management HTML to contain %q", want) - } - } - for _, forbidden := range []string{"统一模型目录", "查看设备模型"} { - if strings.Contains(body, forbidden) { - t.Fatalf("model management page should not contain legacy text %q", forbidden) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"模型管理", "标准模型", "设备模型状态", "完整设备数", "缺失设备数", "版本不一致设备数", "更新全部模型", "非标准模型", "人脸检测"} { + if !strings.Contains(body, want) { + t.Fatalf("expected model management HTML to contain %q", want) + } + } + for _, want := range []string{"1 个 · 更多", "best-640.rknn"} { + if !strings.Contains(body, want) { + t.Fatalf("expected model management HTML to contain %q", want) + } + } + for _, forbidden := range []string{"统一模型目录", "查看设备模型"} { + if strings.Contains(body, forbidden) { + t.Fatalf("model management page should not contain legacy text %q", forbidden) + } + } } func TestUI_ModelsPageLoadsDevicesBeforeBuildingStatusBoard(t *testing.T) { - modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/models/status": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123}]}`)) - default: - http.NotFound(w, r) - } - })) - defer modelServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(modelServer.Listener.Addr().String(), "[::]")) - if err != nil { - t.Fatalf("SplitHostPort: %v", err) - } - agentPort, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("Atoi: %v", err) - } + modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/models/status": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"name":"face_det_scrfd_500m_640_rk3588","file_name":"face_det_scrfd_500m_640_rk3588.rknn","sha256":"sha-1","size_bytes":123}]}`)) + default: + http.NotFound(w, r) + } + })) + defer modelServer.Close() + host, portText, err := net.SplitHostPort(strings.TrimPrefix(modelServer.Listener.Addr().String(), "[::]")) + if err != nil { + t.Fatalf("SplitHostPort: %v", err) + } + agentPort, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("Atoi: %v", err) + } - discoveryConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 35689}) - if err != nil { - t.Fatalf("ListenUDP: %v", err) - } - defer discoveryConn.Close() - done := make(chan struct{}) - go func() { - defer close(done) - buf := make([]byte, 2048) - _ = discoveryConn.SetReadDeadline(time.Now().Add(5 * time.Second)) - n, addr, err := discoveryConn.ReadFromUDP(buf) - if err != nil { - return - } - text := strings.TrimSpace(string(buf[:n])) - lines := strings.SplitN(text, "\n", 3) - if len(lines) < 2 || strings.TrimSpace(lines[0]) != "RK3588SYS_DISCOVERY_V1" { - return - } - var req struct { - ReqID string `json:"req_id"` - } - if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &req); err != nil { - return - } - reply := fmt.Sprintf("RK3588SYS_DISCOVERY_V1\n{\"type\":\"discover_reply\",\"req_id\":%q,\"device_id\":\"edge-01\",\"device_name\":\"入口识别节点\",\"ip\":%q,\"agent_port\":%d,\"media_port\":9000}\n", req.ReqID, host, agentPort) - _, _ = discoveryConn.WriteToUDP([]byte(reply), addr) - }() + discoveryConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 35689}) + if err != nil { + t.Fatalf("ListenUDP: %v", err) + } + defer discoveryConn.Close() + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 2048) + _ = discoveryConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, addr, err := discoveryConn.ReadFromUDP(buf) + if err != nil { + return + } + text := strings.TrimSpace(string(buf[:n])) + lines := strings.SplitN(text, "\n", 3) + if len(lines) < 2 || strings.TrimSpace(lines[0]) != "RK3588SYS_DISCOVERY_V1" { + return + } + var req struct { + ReqID string `json:"req_id"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(lines[1])), &req); err != nil { + return + } + reply := fmt.Sprintf("RK3588SYS_DISCOVERY_V1\n{\"type\":\"discover_reply\",\"req_id\":%q,\"device_id\":\"edge-01\",\"device_name\":\"入口识别节点\",\"ip\":%q,\"agent_port\":%d,\"media_port\":9000}\n", req.ReqID, host, agentPort) + _, _ = discoveryConn.WriteToUDP([]byte(reply), addr) + }() - cfg := &config.Config{ - Concurrency: 1, - OfflineAfterMs: 1000000, - DiscoveryTimeoutMs: 300, - DiscoveryPort: 35689, - } - dbPath := filepath.Join(t.TempDir(), "models.db") - store, err := storage.OpenSQLite(dbPath) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) - discovery := service.NewDiscoveryService(cfg, reg) - agent := service.NewAgentClient(cfg) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(discovery, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } - modelsRepo := storage.NewModelsRepo(store.DB()) - if err := modelsRepo.Save(storage.StandardModelRecord{ - Name: "face_det_scrfd_500m_640_rk3588", - FileName: "face_det_scrfd_500m_640_rk3588.rknn", - Version: "v1", - SHA256: "sha-1", - SizeBytes: 123, - ModelType: "face_detection", - }); err != nil { - t.Fatalf("Save: %v", err) - } - ui.SetDBPath(dbPath) + cfg := &config.Config{ + Concurrency: 1, + OfflineAfterMs: 1000000, + DiscoveryTimeoutMs: 300, + DiscoveryPort: 35689, + } + dbPath := filepath.Join(t.TempDir(), "models.db") + store, err := storage.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + reg := service.NewRegistryService(cfg, nil, storage.NewDevicesRepo(store.DB())) + discovery := service.NewDiscoveryService(cfg, reg) + agent := service.NewAgentClient(cfg) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(discovery, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + modelsRepo := storage.NewModelsRepo(store.DB()) + if err := modelsRepo.Save(storage.StandardModelRecord{ + Name: "face_det_scrfd_500m_640_rk3588", + FileName: "face_det_scrfd_500m_640_rk3588.rknn", + Version: "v1", + SHA256: "sha-1", + SizeBytes: 123, + ModelType: "face_detection", + }); err != nil { + t.Fatalf("Save: %v", err) + } + ui.SetDBPath(dbPath) - req := httptest.NewRequest(http.MethodGet, "/ui/models", nil) - rr := httptest.NewRecorder() - ui.pageModels(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/models", nil) + rr := httptest.NewRecorder() + ui.pageModels(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"入口识别节点", "设备模型状态", "一致"} { - if !strings.Contains(body, want) { - t.Fatalf("expected model page to contain %q after lazy device load, got:\n%s", want, body) - } - } - if strings.Contains(body, "暂无设备模型状态") { - t.Fatalf("expected model page to build status board after lazy device load, got:\n%s", body) - } - <-done + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"入口识别节点", "设备模型状态", "一致"} { + if !strings.Contains(body, want) { + t.Fatalf("expected model page to contain %q after lazy device load, got:\n%s", want, body) + } + } + if strings.Contains(body, "暂无设备模型状态") { + t.Fatalf("expected model page to build status board after lazy device load, got:\n%s", body) + } + <-done } func TestUI_ActionModelSyncCreatesTask(t *testing.T) { - ui := newTestUI(t) - form := url.Values{} - form.Set("action", "model_sync_all") - form.Add("device_id", "edge-01") - req := httptest.NewRequest(http.MethodPost, "/ui/models/sync", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + ui := newTestUI(t) + form := url.Values{} + form.Set("action", "model_sync_all") + form.Add("device_id", "edge-01") + req := httptest.NewRequest(http.MethodPost, "/ui/models/sync", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionModelSync(rr, req) + ui.actionModelSync(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - tasks := ui.tasks.ListTasks() - if len(tasks) != 1 || tasks[0].Type != "model_sync_all" { - t.Fatalf("unexpected tasks: %#v", tasks) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + tasks := ui.tasks.ListTasks() + if len(tasks) != 1 || tasks[0].Type != "model_sync_all" { + t.Fatalf("unexpected tasks: %#v", tasks) + } } func TestUI_ResourcesPageShowsUnifiedResourceStatus(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/resources") + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/resources") - for _, want := range []string{"资源管理", "资源概览", "标准资源", "设备资源状态"} { - if !strings.Contains(html, want) { - t.Fatalf("expected resources HTML to contain %q", want) - } - } - for _, forbidden := range []string{"上传模型", "单台设备"} { - if strings.Contains(html, forbidden) { - t.Fatalf("resources page should not contain legacy text %q", forbidden) - } - } + for _, want := range []string{"资源管理", "资源概览", "标准资源", "设备资源状态"} { + if !strings.Contains(html, want) { + t.Fatalf("expected resources HTML to contain %q", want) + } + } + for _, forbidden := range []string{"上传模型", "单台设备"} { + if strings.Contains(html, forbidden) { + t.Fatalf("resources page should not contain legacy text %q", forbidden) + } + } } func TestUI_LogAuditPageRendersLogAndMetricLinks(t *testing.T) { - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/diagnostics", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/diagnostics", nil) + rr := httptest.NewRecorder() - ui.pageDiagnostics(rr, req) + ui.pageDiagnostics(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"日志分析", "审计记录", "入口识别节点", "诊断日志", "运行指标"} { - if !strings.Contains(body, want) { - t.Fatalf("expected diagnostics HTML to contain %q", want) - } - } - if strings.Contains(body, "进入系统状态") { - t.Fatalf("log audit page should not contain system status section") - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"日志分析", "审计记录", "入口识别节点", "诊断日志", "运行指标"} { + if !strings.Contains(body, want) { + t.Fatalf("expected diagnostics HTML to contain %q", want) + } + } + if strings.Contains(body, "进入系统状态") { + t.Fatalf("log audit page should not contain system status section") + } } func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) { - t.Skip("sidebar expectations updated by new IA; replace with dedicated scene-template/unit/assignment assertions") - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) - rr := httptest.NewRecorder() + t.Skip("sidebar expectations updated by new IA; replace with dedicated scene-template/unit/assignment assertions") + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil) + rr := httptest.NewRecorder() - ui.pageDevices(rr, req) + ui.pageDevices(rr, req) - body := rr.Body.String() - for _, want := range []string{ - "总览", - "设备", - "场景配置", - "基础配置", - "任务", - "系统管理", - "模型管理", - "资源管理", - "日志审计", - "系统状态", - `href="/ui/dashboard"`, - `href="/ui/devices"`, - `href="/ui/plans"`, - `href="/ui/assets"`, - `href="/ui/tasks"`, - `href="/ui/models"`, - `href="/ui/resources"`, - `href="/ui/diagnostics"`, - `href="/ui/system"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected sidebar to contain %q", want) - } - } - for _, old := range []string{"配置管理", "操作审计", ``} { - if strings.Contains(body, old) { - t.Fatalf("sidebar should not contain old label %q", old) - } - } + body := rr.Body.String() + for _, want := range []string{ + "总览", + "设备", + "场景配置", + "基础配置", + "任务", + "系统管理", + "模型管理", + "资源管理", + "日志审计", + "系统状态", + `href="/ui/dashboard"`, + `href="/ui/devices"`, + `href="/ui/plans"`, + `href="/ui/assets"`, + `href="/ui/tasks"`, + `href="/ui/models"`, + `href="/ui/resources"`, + `href="/ui/diagnostics"`, + `href="/ui/system"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected sidebar to contain %q", want) + } + } + for _, old := range []string{"配置管理", "操作审计", ``} { + if strings.Contains(body, old) { + t.Fatalf("sidebar should not contain old label %q", old) + } + } } func TestUI_DeviceConfigPageShowsDeviceSelector(t *testing.T) { - ui := newTestUI(t) - req := httptest.NewRequest(http.MethodGet, "/ui/device-config", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + req := httptest.NewRequest(http.MethodGet, "/ui/device-config", nil) + rr := httptest.NewRecorder() - ui.pageDeviceConfig(rr, req) + ui.pageDeviceConfig(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"单设备配置已并入设备详情", "设备入口", "edge-01", "入口识别节点", `href="/ui/devices/edge-01#device-config"`} { - if !strings.Contains(body, want) { - t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"识别模板", "调试参数"} { - if strings.Contains(body, forbidden) { - t.Fatalf("expected config selector page to avoid asset-library copy %q", forbidden) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"单设备配置已并入设备详情", "设备入口", "edge-01", "入口识别节点", `href="/ui/devices/edge-01#device-config"`} { + if !strings.Contains(body, want) { + t.Fatalf("expected config selector page to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"识别模板", "调试参数"} { + if strings.Contains(body, forbidden) { + t.Fatalf("expected config selector page to avoid asset-library copy %q", forbidden) + } + } } func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) { - ui := newTestUI(t) - routes, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - rr := httptest.NewRecorder() + ui := newTestUI(t) + routes, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + rr := httptest.NewRecorder() - routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/device-config/edge-01", nil)) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Location"); got != "/ui/devices/edge-01#device-config" { - t.Fatalf("expected device-config detail redirect to device workspace, got %q", got) - } + routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/device-config/edge-01", nil)) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Location"); got != "/ui/devices/edge-01#device-config" { + t.Fatalf("expected device-config detail redirect to device workspace, got %q", got) + } } func TestUI_ActionDeviceActionCanRenderControlPage(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodPost && r.URL.Path == "/v1/media-server/reload": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "metadata": {"config_id": "preview_edge-01", "config_version": "v2"}, - "previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}}, - "media_server": {"running": true, "pid": 1810489} - }`)) - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer agentServer.Close() + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/media-server/reload": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "metadata": {"config_id": "preview_edge-01", "config_version": "v2"}, + "previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}}, + "media_server": {"running": true, "pid": 1810489} + }`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - form := url.Values{} - form.Set("action", "reload") - form.Set("return_to", "config") - req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/action", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rctx := chi.NewRouteContext() - rctx.URLParams.Add("id", "edge-01") - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("action", "reload") + form.Set("return_to", "config") + req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/action", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "edge-01") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() - ui.actionDeviceAction(rr, req) + ui.actionDeviceAction(rr, req) - body := rr.Body.String() - for _, want := range []string{"设备工作台", "运行与服务", "POST /v1/media-server/reload", "执行结果摘要"} { - if !strings.Contains(body, want) { - t.Fatalf("expected control page result HTML to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{"设备工作台", "运行与服务", "POST /v1/media-server/reload", "执行结果摘要"} { + if !strings.Contains(body, want) { + t.Fatalf("expected control page result HTML to contain %q, got:\n%s", want, body) + } + } } func TestUI_ActionDeviceConfigCandidateApplyCanRenderControlPage(t *testing.T) { - t.Skip("legacy candidate-config apply flow is no longer exposed in migrated UI") - statusCalls := 0 - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status": - statusCalls++ - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "ok": true, - "metadata": {"config_id": "preview_edge-01", "config_version": "v2"}, - "candidate": {"exists": false, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, - "previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}}, - "media_server": {"running": true, "pid": 1810489} - }`)) - case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer agentServer.Close() + t.Skip("legacy candidate-config apply flow is no longer exposed in migrated UI") + statusCalls := 0 + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status": + statusCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "metadata": {"config_id": "preview_edge-01", "config_version": "v2"}, + "candidate": {"exists": false, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"}, + "previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}}, + "media_server": {"running": true, "pid": 1810489} + }`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer agentServer.Close() - host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) - if err != nil { - t.Fatalf("parse test server address: %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } + host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://")) + if err != nil { + t.Fatalf("parse test server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - agent := service.NewAgentClient(cfg) - reg := service.NewRegistryService(cfg, agent) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) - tasks := service.NewTaskService(cfg, agent, reg) - ui, err := NewUI(nil, reg, agent, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + agent := service.NewAgentClient(cfg) + reg := service.NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true}) + tasks := service.NewTaskService(cfg, agent, reg) + ui, err := NewUI(nil, reg, agent, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - form := url.Values{} - form.Set("return_to", "config") - req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rctx := chi.NewRouteContext() - rctx.URLParams.Add("id", "edge-01") - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("return_to", "config") + req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "edge-01") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() - ui.actionDeviceConfigCandidateApply(rr, req) + ui.actionDeviceConfigCandidateApply(rr, req) - if statusCalls < 2 { - t.Fatalf("expected status to be refreshed for control page, got %d calls", statusCalls) - } - body := rr.Body.String() - for _, want := range []string{"设备工作台", "应用候选配置结果", "preview_edge-01", "运行中"} { - if !strings.Contains(body, want) { - t.Fatalf("expected control page apply result HTML to contain %q, got:\n%s", want, body) - } - } + if statusCalls < 2 { + t.Fatalf("expected status to be refreshed for control page, got %d calls", statusCalls) + } + body := rr.Body.String() + for _, want := range []string{"设备工作台", "应用候选配置结果", "preview_edge-01", "运行中"} { + if !strings.Contains(body, want) { + t.Fatalf("expected control page apply result HTML to contain %q, got:\n%s", want, body) + } + } } func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"services":[{"name":"object_storage_main","type":"object_storage","required":false,"description":"对象存储"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`) - mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"services":[{"name":"object_storage_main","type":"object_storage","required":false,"description":"对象存储"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) + req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil) + rr := httptest.NewRecorder() - ui.pageAssets(rr, req) + ui.pageAssets(rr, req) - body := rr.Body.String() - for _, want := range []string{"配置中心", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} { - if !strings.Contains(body, want) { - t.Fatalf("expected assets HTML to contain %q", want) - } - } - if !strings.Contains(body, "minio_main") { - t.Fatalf("expected assets overview to contain integration service, got:\n%s", body) - } + body := rr.Body.String() + for _, want := range []string{"配置中心", "总览", "视频源", "识别模板", "第三方服务", "调试参数", "std_workshop_face_recognition_shoe_alarm", "face_debug"} { + if !strings.Contains(body, want) { + t.Fatalf("expected assets HTML to contain %q", want) + } + } + if !strings.Contains(body, "minio_main") { + t.Fatalf("expected assets overview to contain integration service, got:\n%s", body) + } } func TestUI_AssetIntegrationsPageShowsStructuredServices(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations", nil) - rr := httptest.NewRecorder() - ui.pageAssetIntegrations(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations", nil) + rr := httptest.NewRecorder() + ui.pageAssetIntegrations(rr, req) - body := rr.Body.String() - for _, want := range []string{ - "第三方服务列表", - "第三方服务详情", - "新增服务", - "编辑", - "删除", - "minio_main", - "token_main", - "对象存储", - "认证服务", - "http://10.0.0.49:9000 / myminio", - "1", - "查看模式", - `minio_main`, - `data-nav-row`, - `data-nav-href="/ui/assets/integrations?name=minio_main"`, - `class="selected"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected integrations page to contain %q, got:\n%s", want, body) - } - } - if strings.Contains(body, ">保存") { - t.Fatalf("expected readonly details to hide save button, got:\n%s", body) - } + body := rr.Body.String() + for _, want := range []string{ + "第三方服务列表", + "第三方服务详情", + "新增服务", + "编辑", + "删除", + "minio_main", + "token_main", + "对象存储", + "认证服务", + "http://10.0.0.49:9000 / myminio", + "1", + "查看模式", + `minio_main`, + `data-nav-row`, + `data-nav-href="/ui/assets/integrations?name=minio_main"`, + `class="selected"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected integrations page to contain %q, got:\n%s", want, body) + } + } + if strings.Contains(body, ">保存") { + t.Fatalf("expected readonly details to hide save button, got:\n%s", body) + } } func TestUI_AssetIntegrationsPageNewModeClearsDetailForm(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?new=1", nil) - rr := httptest.NewRecorder() - ui.pageAssetIntegrations(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?new=1", nil) + rr := httptest.NewRecorder() + ui.pageAssetIntegrations(rr, req) - body := rr.Body.String() - if strings.Contains(body, `value="minio_main"`) { - t.Fatalf("expected new mode to clear selected service detail, got:\n%s", body) - } - if !strings.Contains(body, `name="name" value="" autofocus`) { - t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body) - } + body := rr.Body.String() + if strings.Contains(body, `value="minio_main"`) { + t.Fatalf("expected new mode to clear selected service detail, got:\n%s", body) + } + if !strings.Contains(body, `name="name" value="" autofocus`) { + t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body) + } } func TestUI_AssetIntegrationsPageEditModeEnablesFormAndAutofocusesName(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?name=minio_main&edit=1", nil) - rr := httptest.NewRecorder() - ui.pageAssetIntegrations(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?name=minio_main&edit=1", nil) + rr := httptest.NewRecorder() + ui.pageAssetIntegrations(rr, req) - body := rr.Body.String() - if !strings.Contains(body, `name="name" value="minio_main" autofocus`) { - t.Fatalf("expected edit mode to autofocus selected name field, got:\n%s", body) - } - if !strings.Contains(body, ">保存") { - t.Fatalf("expected edit mode to show save button, got:\n%s", body) - } + body := rr.Body.String() + if !strings.Contains(body, `name="name" value="minio_main" autofocus`) { + t.Fatalf("expected edit mode to autofocus selected name field, got:\n%s", body) + } + if !strings.Contains(body, ">保存") { + t.Fatalf("expected edit mode to show save button, got:\n%s", body) + } } func TestUI_ActionAssetIntegrationSaveCreatesService(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - form := url.Values{} - form.Set("name", "minio_main") - form.Set("type", "object_storage") - form.Set("description", "主对象存储") - form.Set("enabled", "1") - form.Set("endpoint", "http://10.0.0.49:9000") - form.Set("bucket", "myminio") - form.Set("access_key", "admin") - form.Set("secret_key", "password") - req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("name", "minio_main") + form.Set("type", "object_storage") + form.Set("description", "主对象存储") + form.Set("enabled", "1") + form.Set("endpoint", "http://10.0.0.49:9000") + form.Set("bucket", "myminio") + form.Set("access_key", "admin") + form.Set("secret_key", "password") + req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionAssetIntegrationSave(rr, req) + ui.actionAssetIntegrationSave(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - record, err := repo.GetIntegrationService("minio_main") - if err != nil { - t.Fatalf("GetIntegrationService: %v", err) - } - if record == nil { - t.Fatal("expected service record") - } - if record.ServiceType != "object_storage" { - t.Fatalf("unexpected service type: %#v", record) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + record, err := repo.GetIntegrationService("minio_main") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if record == nil { + t.Fatal("expected service record") + } + if record.ServiceType != "object_storage" { + t.Fatalf("unexpected service type: %#v", record) + } } func TestUI_ActionAssetIntegrationDeleteBlocksReferencedService(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`) + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_main"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations/minio_main/delete", nil) - req = withChiURLParam(req, "name", "minio_main") - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/ui/assets/integrations/minio_main/delete", nil) + req = withChiURLParam(req, "name", "minio_main") + rr := httptest.NewRecorder() - ui.actionAssetIntegrationDelete(rr, req) + ui.actionAssetIntegrationDelete(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Location"); !strings.Contains(got, "error=") { - t.Fatalf("expected error redirect, got %q", got) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Location"); !strings.Contains(got, "error=") { + t.Fatalf("expected error redirect, got %q", got) + } } func TestUI_AssetVideoSourcesPageShowsStructuredSources(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","fps":"25"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","fps":"25"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources", nil) - rr := httptest.NewRecorder() - ui.pageAssetVideoSources(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources", nil) + rr := httptest.NewRecorder() + ui.pageAssetVideoSources(rr, req) - body := rr.Body.String() - for _, want := range []string{ - "视频源列表", - "视频源详情", - "新增视频源", - "编辑", - "删除", - "gate_cam_01", - "RTSP", - "东门入口", - "1080p", - "查看模式", - `gate_cam_01`, - `data-nav-row`, - `data-nav-href="/ui/assets/video-sources?name=gate_cam_01"`, - `class="selected"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected video sources page to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{ + "视频源列表", + "视频源详情", + "新增视频源", + "编辑", + "删除", + "gate_cam_01", + "RTSP", + "东门入口", + "1080p", + "查看模式", + `gate_cam_01`, + `data-nav-row`, + `data-nav-href="/ui/assets/video-sources?name=gate_cam_01"`, + `class="selected"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected video sources page to contain %q, got:\n%s", want, body) + } + } } func TestUI_AssetVideoSourcesPageNewModeClearsDetailForm(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources?new=1", nil) - rr := httptest.NewRecorder() - ui.pageAssetVideoSources(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/assets/video-sources?new=1", nil) + rr := httptest.NewRecorder() + ui.pageAssetVideoSources(rr, req) - body := rr.Body.String() - if strings.Contains(body, `value="gate_cam_01"`) { - t.Fatalf("expected new mode to clear selected source detail, got:\n%s", body) - } - if !strings.Contains(body, `name="name" value="" autofocus`) { - t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body) - } + body := rr.Body.String() + if strings.Contains(body, `value="gate_cam_01"`) { + t.Fatalf("expected new mode to clear selected source detail, got:\n%s", body) + } + if !strings.Contains(body, `name="name" value="" autofocus`) { + t.Fatalf("expected new mode to autofocus empty name field, got:\n%s", body) + } } func TestUI_PlanPageShowsVideoSourceSelector(t *testing.T) { - t.Skip("legacy scene-config channel editor replaced by recognition-unit page") - ui := newTestUI(t) - root := createProfileEditorMediaRepo(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ + t.Skip("legacy scene-config channel editor replaced by recognition-unit page") + ui := newTestUI(t) + root := createProfileEditorMediaRepo(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ "name": "std_workshop_face_recognition_shoe_alarm", "source": "standard", "slots": { @@ -2599,69 +2599,69 @@ func TestUI_PlanPageShowsVideoSourceSelector(t *testing.T) { }, "template": {"nodes": [], "edges": []} }`) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=local_3588_test&edit=1", nil) - rr := httptest.NewRecorder() - ui.pagePlans(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=local_3588_test&edit=1", nil) + rr := httptest.NewRecorder() + ui.pagePlans(rr, req) - body := rr.Body.String() - for _, want := range []string{"主视频输入", `name="instances[0].input_bindings.video_input_main.video_source_ref"`, "gate_cam_01 - 东门入口", "编辑模式", "正在编辑场景配置"} { - if !strings.Contains(body, want) { - t.Fatalf("expected plan page to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{"主视频输入", `name="instances[0].input_bindings.video_input_main.video_source_ref"`, "gate_cam_01 - 东门入口", "编辑模式", "正在编辑场景配置"} { + if !strings.Contains(body, want) { + t.Fatalf("expected plan page to contain %q, got:\n%s", want, body) + } + } } func TestUI_ActionAssetVideoSourceSavePreservesFormOnError(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, storage.NewAssetsRepo(store.DB())) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "line_a.json"), `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, storage.NewAssetsRepo(store.DB())) - form := url.Values{} - form.Set("name", "东门主入口") - form.Set("source_type", "rtsp") - form.Set("area", "东门") - form.Set("description", "入口相机") - req := httptest.NewRequest(http.MethodPost, "/ui/assets/video-sources", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("name", "东门主入口") + form.Set("source_type", "rtsp") + form.Set("area", "东门") + form.Set("description", "入口相机") + req := httptest.NewRequest(http.MethodPost, "/ui/assets/video-sources", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionAssetVideoSourceSave(rr, req) + ui.actionAssetVideoSourceSave(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected inline render on error, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, want := range []string{"视频源地址不能为空", `name="name" value="东门主入口" autofocus`, `name="area" value="东门"`, `name="description" value="入口相机"`} { - if !strings.Contains(body, want) { - t.Fatalf("expected preserved error form to contain %q, got:\n%s", want, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected inline render on error, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"视频源地址不能为空", `name="name" value="东门主入口" autofocus`, `name="area" value="东门"`, `name="description" value="入口相机"`} { + if !strings.Contains(body, want) { + t.Fatalf("expected preserved error form to contain %q, got:\n%s", want, body) + } + } } func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) { - t.Skip("legacy profile asset page replaced by scene-template and recognition-unit pages") - ui := newTestUI(t) - root := t.TempDir() - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + t.Skip("legacy profile asset page replaced by scene-template and recognition-unit pages") + ui := newTestUI(t) + root := t.TempDir() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name":"local_3588_test", "business_name":"A厂区视觉识别", "description":"test profile", @@ -2675,50 +2675,50 @@ func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) { "output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8","publish_rtsp_port":8555,"publish_rtsp_path":"/live/cam1","channel_no":"cam1"}} }] }`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil) - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, func() *chi.Context { - rctx := chi.NewRouteContext() - rctx.URLParams.Add("name", "local_3588_test") - return rctx - }())) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, func() *chi.Context { + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", "local_3588_test") + return rctx + }())) + rr := httptest.NewRecorder() - ui.pageAssetProfile(rr, req) + ui.pageAssetProfile(rr, req) - body := rr.Body.String() - for _, want := range []string{"场景配置", "local_3588_test", "业务名称", "A厂区视觉识别", "站点名", "A厂区", "东门入口", "主视频输入", "主视频输出"} { - if !strings.Contains(body, want) { - t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{"高级设置 JSON", `name="instances[0].advanced_params"`} { - if strings.Contains(body, forbidden) { - t.Fatalf("profile asset HTML should omit %q, got:\n%s", forbidden, body) - } - } - for _, forbidden := range []string{"MinIO", "取 token 接口", "告警上报接口", "设备编号"} { - if strings.Contains(body, forbidden) { - t.Fatalf("profile asset HTML should no longer contain shared template field %q, got:\n%s", forbidden, body) - } - } + body := rr.Body.String() + for _, want := range []string{"场景配置", "local_3588_test", "业务名称", "A厂区视觉识别", "站点名", "A厂区", "东门入口", "主视频输入", "主视频输出"} { + if !strings.Contains(body, want) { + t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{"高级设置 JSON", `name="instances[0].advanced_params"`} { + if strings.Contains(body, forbidden) { + t.Fatalf("profile asset HTML should omit %q, got:\n%s", forbidden, body) + } + } + for _, forbidden := range []string{"MinIO", "取 token 接口", "告警上报接口", "设备编号"} { + if strings.Contains(body, forbidden) { + t.Fatalf("profile asset HTML should no longer contain shared template field %q, got:\n%s", forbidden, body) + } + } } func TestUI_ProfileAssetPageEditActionOpensInstanceEditor(t *testing.T) { - t.Skip("legacy profile asset page replaced by scene-template and recognition-unit pages") - ui := newTestUI(t) - root := t.TempDir() - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + t.Skip("legacy profile asset page replaced by scene-template and recognition-unit pages") + ui := newTestUI(t) + root := t.TempDir() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name":"local_3588_test", "business_name":"A厂区视觉识别", "instances":[{ @@ -2729,61 +2729,61 @@ func TestUI_ProfileAssetPageEditActionOpensInstanceEditor(t *testing.T) { "output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8"}} }] }`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil) - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, func() *chi.Context { - rctx := chi.NewRouteContext() - rctx.URLParams.Add("name", "local_3588_test") - return rctx - }())) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles/local_3588_test?edit=1", nil) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, func() *chi.Context { + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", "local_3588_test") + return rctx + }())) + rr := httptest.NewRecorder() - ui.pageAssetProfile(rr, req) + ui.pageAssetProfile(rr, req) - body := rr.Body.String() - for _, want := range []string{ - `id="active-instance-input"`, - `class="btn ghost js-open-instance-editor"`, - `data-instance-index="0"`, - `data-instance-editor="0"`, - `保存场景配置`, - `通道详情`, - `场景名称*`, - `通道名*`, - `主视频输入*`, - `const instanceEditorToggle = event.target.closest(".js-open-instance-editor");`, - `activeInput.value = index;`, - `panel.hidden = panel.getAttribute("data-instance-editor") !== index;`, - `input:not([type=hidden]):not([readonly]), textarea, select`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{ - `href="#profile-instance-0"`, - `class="card collapsible profile-instance-editor"`, - `保存`, - } { - if strings.Contains(body, forbidden) { - t.Fatalf("expected legacy profile editor markup to omit %q, got:\n%s", forbidden, body) - } - } + body := rr.Body.String() + for _, want := range []string{ + `id="active-instance-input"`, + `class="btn ghost js-open-instance-editor"`, + `data-instance-index="0"`, + `data-instance-editor="0"`, + `保存场景配置`, + `通道详情`, + `场景名称*`, + `通道名*`, + `主视频输入*`, + `const instanceEditorToggle = event.target.closest(".js-open-instance-editor");`, + `activeInput.value = index;`, + `panel.hidden = panel.getAttribute("data-instance-editor") !== index;`, + `input:not([type=hidden]):not([readonly]), textarea, select`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected profile asset HTML to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{ + `href="#profile-instance-0"`, + `class="card collapsible profile-instance-editor"`, + `保存`, + } { + if strings.Contains(body, forbidden) { + t.Fatalf("expected legacy profile editor markup to omit %q, got:\n%s", forbidden, body) + } + } } func TestUI_PlanPageHighlightsSelectedSceneAndShowsCrudActions(t *testing.T) { - t.Skip("legacy scene-config page replaced by scene-template page") - ui := newTestUI(t) - root := createProfileEditorMediaRepo(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ + t.Skip("legacy scene-config page replaced by scene-template page") + ui := newTestUI(t) + root := createProfileEditorMediaRepo(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ "name": "std_workshop_face_recognition_shoe_alarm", "source": "standard", "slots": { @@ -2796,48 +2796,48 @@ func TestUI_PlanPageHighlightsSelectedSceneAndShowsCrudActions(t *testing.T) { }, "template": {"nodes": [], "edges": []} }`) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile line_a: %v", err) - } - if err := repo.SaveProfile("line_b", "std_workshop_face_recognition_shoe_alarm", "产线B", "B 线", `{"name":"line_b","business_name":"产线B","instances":[{"name":"cam2","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile line_b: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile line_a: %v", err) + } + if err := repo.SaveProfile("line_b", "std_workshop_face_recognition_shoe_alarm", "产线B", "B 线", `{"name":"line_b","business_name":"产线B","instances":[{"name":"cam2","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile line_b: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_b", nil) - rr := httptest.NewRecorder() - ui.pagePlans(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_b", nil) + rr := httptest.NewRecorder() + ui.pagePlans(rr, req) - body := rr.Body.String() - for _, want := range []string{ - `href="/ui/plans?new=1"`, - `href="/ui/plans?name=line_b&edit=1"`, - `action="/ui/plans/line_b/delete"`, - `href="/ui/plans?name=line_b"`, - `line_b`, - `查看模式`, - `data-profile-row`, - `data-profile-href="/ui/plans?name=line_b"`, - `class="selected"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected plan page to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{ + `href="/ui/plans?new=1"`, + `href="/ui/plans?name=line_b&edit=1"`, + `action="/ui/plans/line_b/delete"`, + `href="/ui/plans?name=line_b"`, + `line_b`, + `查看模式`, + `data-profile-row`, + `data-profile-href="/ui/plans?name=line_b"`, + `class="selected"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected plan page to contain %q, got:\n%s", want, body) + } + } } func TestUI_ActionPlanDeleteBlocksProfileUsedByDevice(t *testing.T) { - t.Skip("legacy scene-config page replaced by scene-template/device-assignment flow") - ui := newTestUI(t) - root := createProfileEditorMediaRepo(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ + t.Skip("legacy scene-config page replaced by scene-template/device-assignment flow") + ui := newTestUI(t) + root := createProfileEditorMediaRepo(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ "name": "std_workshop_face_recognition_shoe_alarm", "source": "standard", "slots": { @@ -2847,47 +2847,47 @@ func TestUI_ActionPlanDeleteBlocksProfileUsedByDevice(t *testing.T) { }, "template": {"nodes": [], "edges": []} }`) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) - if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{ - DeviceID: "edge-01", - TemplateName: "std_workshop_face_recognition_shoe_alarm", - ProfileName: "line_a", - }); err != nil { - t.Fatalf("Upsert: %v", err) - } - ui.stateRepo = stateRepo + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) + if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{ + DeviceID: "edge-01", + TemplateName: "std_workshop_face_recognition_shoe_alarm", + ProfileName: "line_a", + }); err != nil { + t.Fatalf("Upsert: %v", err) + } + ui.stateRepo = stateRepo - req := httptest.NewRequest(http.MethodPost, "/ui/plans/line_a/delete", nil) - req = withChiURLParam(req, "name", "line_a") - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/ui/plans/line_a/delete", nil) + req = withChiURLParam(req, "name", "line_a") + rr := httptest.NewRecorder() - ui.actionPlanDelete(rr, req) + ui.actionPlanDelete(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - loc := rr.Header().Get("Location") - if !strings.Contains(loc, "/ui/plans?") || !strings.Contains(loc, "error=") || !strings.Contains(loc, "edge-01") { - t.Fatalf("expected redirect with usage error, got %q", loc) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + loc := rr.Header().Get("Location") + if !strings.Contains(loc, "/ui/plans?") || !strings.Contains(loc, "error=") || !strings.Contains(loc, "edge-01") { + t.Fatalf("expected redirect with usage error, got %q", loc) + } } func TestUI_ActionPlanCreateRedirectsToNewScene(t *testing.T) { - t.Skip("legacy scene-config page replaced by scene-template page") - ui := newTestUI(t) - root := createProfileEditorMediaRepo(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ + t.Skip("legacy scene-config page replaced by scene-template page") + ui := newTestUI(t) + root := createProfileEditorMediaRepo(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ "name": "std_workshop_face_recognition_shoe_alarm", "source": "standard", "slots": { @@ -2900,42 +2900,42 @@ func TestUI_ActionPlanCreateRedirectsToNewScene(t *testing.T) { }, "template": {"nodes": [], "edges": []} }`) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - form := url.Values{} - form.Set("profile_name", "line_c") - form.Set("business_name", "产线C") - form.Set("description", "C 线") - form.Set("instances[0].name", "cam1") - form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm") - form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01") - form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_port", "8555") - req := httptest.NewRequest(http.MethodPost, "/ui/plans/create", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("profile_name", "line_c") + form.Set("business_name", "产线C") + form.Set("description", "C 线") + form.Set("instances[0].name", "cam1") + form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm") + form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01") + form.Set("instances[0].output_bindings.stream_output_main.publish_rtsp_port", "8555") + req := httptest.NewRequest(http.MethodPost, "/ui/plans/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionPlanCreate(rr, req) + ui.actionPlanCreate(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Location"); got != "/ui/plans?msg=%e5%9c%ba%e6%99%af%e9%85%8d%e7%bd%ae%e5%b7%b2%e4%bf%9d%e5%ad%98&name=line_c" { - t.Fatalf("expected redirect to new scene, got %q", got) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Location"); got != "/ui/plans?msg=%e5%9c%ba%e6%99%af%e9%85%8d%e7%bd%ae%e5%b7%b2%e4%bf%9d%e5%ad%98&name=line_c" { + t.Fatalf("expected redirect to new scene, got %q", got) + } } func TestUI_PlanPageUsesExportLabelAndSingleEditEntry(t *testing.T) { - t.Skip("legacy scene-config page replaced by scene-template page") - ui := newTestUI(t) - root := createProfileEditorMediaRepo(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ + t.Skip("legacy scene-config page replaced by scene-template page") + ui := newTestUI(t) + root := createProfileEditorMediaRepo(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{ "name": "std_workshop_face_recognition_shoe_alarm", "source": "standard", "slots": { @@ -2945,42 +2945,42 @@ func TestUI_PlanPageUsesExportLabelAndSingleEditEntry(t *testing.T) { }, "template": {"nodes": [], "edges": []} }`) - mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) - if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustSaveVideoSource(t, repo, "gate_cam_01", `{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","config":{"url":"rtsp://10.0.0.1/live"}}`) + if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "产线A", "A 线", `{"name":"line_a","business_name":"产线A","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_a", nil) - rr := httptest.NewRecorder() - ui.pagePlans(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/plans?name=line_a", nil) + rr := httptest.NewRecorder() + ui.pagePlans(rr, req) - body := rr.Body.String() - if strings.Contains(body, "另存为 JSON") { - t.Fatalf("expected export label update, got:\n%s", body) - } - if !strings.Contains(body, "导出为 JSON") { - t.Fatalf("expected export label, got:\n%s", body) - } - if got := strings.Count(body, `href="/ui/plans?name=line_a&edit=1"`); got != 1 { - t.Fatalf("expected only list header edit action to remain, got %d", got) - } - if got := strings.Count(body, ">编辑编辑span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px}`, - `--red:#e46f72`, - `--red:#a43f3f`, - `--red:#d66a63`, - } { - if !strings.Contains(text, want) { - t.Fatalf("expected stylesheet to contain %q", want) - } - } + css, err := os.ReadFile("ui/assets/style.css") + if err != nil { + t.Fatalf("read stylesheet: %v", err) + } + text := string(css) + for _, want := range []string{ + `.field-grid label>span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px}`, + `--red:#e46f72`, + `--red:#a43f3f`, + `--red:#d66a63`, + } { + if !strings.Contains(text, want) { + t.Fatalf("expected stylesheet to contain %q", want) + } + } } func TestUI_ActionAssetProfileSaveAddInstanceDoesNotValidateBlankDraft(t *testing.T) { - t.Skip("legacy add-instance flow removed in scene-template migration") - ui := newTestUI(t) - root := t.TempDir() - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + t.Skip("legacy add-instance flow removed in scene-template migration") + ui := newTestUI(t) + root := t.TempDir() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{}],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name":"local_3588_test", "business_name":"A厂区视觉识别", "instances":[{ @@ -3158,1438 +3158,1438 @@ func TestUI_ActionAssetProfileSaveAddInstanceDoesNotValidateBlankDraft(t *testin "input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}} }] }`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - form := url.Values{} - form.Set("profile_name", "local_3588_test") - form.Set("business_name", "A厂区视觉识别") - form.Set("active_instance", "0") - form.Set("instances[0].name", "cam1") - form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm") - form.Set("instances[0].display_name", "东门入口") - form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01") - form.Set("add_instance", "1") + form := url.Values{} + form.Set("profile_name", "local_3588_test") + form.Set("business_name", "A厂区视觉识别") + form.Set("active_instance", "0") + form.Set("instances[0].name", "cam1") + form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm") + form.Set("instances[0].display_name", "东门入口") + form.Set("instances[0].input_bindings.video_input_main.video_source_ref", "gate_cam_01") + form.Set("add_instance", "1") - req := httptest.NewRequest(http.MethodPost, "/ui/assets/profiles/local_3588_test", strings.NewReader(form.Encode())) - req = withChiURLParam(req, "name", "local_3588_test") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/ui/assets/profiles/local_3588_test", strings.NewReader(form.Encode())) + req = withChiURLParam(req, "name", "local_3588_test") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionAssetProfileSave(rr, req) + ui.actionAssetProfileSave(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - body := rr.Body.String() - for _, forbidden := range []string{"instance name is required", "video source is required"} { - if strings.Contains(body, forbidden) { - t.Fatalf("expected add instance draft to avoid validation error %q, got:\n%s", forbidden, body) - } - } - for _, want := range []string{ - `value="1"`, - `id="profile-instance-1"`, - `data-instance-editor="1"`, - `name="instances[1].name" value="cam2"`, - `name="instances[1].output_bindings.stream_output_main.publish_rtsp_port" value="8555"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected add instance response to contain %q, got:\n%s", want, body) - } - } - for _, forbidden := range []string{ - `name="instances[1].output_bindings.stream_output_main.publish_hls_path"`, - `name="instances[1].output_bindings.stream_output_main.publish_rtsp_path"`, - `name="instances[1].output_bindings.stream_output_main.channel_no"`, - } { - if strings.Contains(body, forbidden) { - t.Fatalf("expected add instance response to omit %q, got:\n%s", forbidden, body) - } - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, forbidden := range []string{"instance name is required", "video source is required"} { + if strings.Contains(body, forbidden) { + t.Fatalf("expected add instance draft to avoid validation error %q, got:\n%s", forbidden, body) + } + } + for _, want := range []string{ + `value="1"`, + `id="profile-instance-1"`, + `data-instance-editor="1"`, + `name="instances[1].name" value="cam2"`, + `name="instances[1].output_bindings.stream_output_main.publish_rtsp_port" value="8555"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected add instance response to contain %q, got:\n%s", want, body) + } + } + for _, forbidden := range []string{ + `name="instances[1].output_bindings.stream_output_main.publish_hls_path"`, + `name="instances[1].output_bindings.stream_output_main.publish_rtsp_path"`, + `name="instances[1].output_bindings.stream_output_main.channel_no"`, + } { + if strings.Contains(body, forbidden) { + t.Fatalf("expected add instance response to omit %q, got:\n%s", forbidden, body) + } + } } func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`) - mustSaveTemplate(t, repo, "helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{}],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "template", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","description":"template","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]},"template":{"nodes":[{},{}],"edges":[[]]}}`) + mustSaveTemplate(t, repo, "helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{}],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates?name=helmet", nil) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates?name=helmet", nil) + rr := httptest.NewRecorder() - ui.pageAssetTemplates(rr, req) + ui.pageAssetTemplates(rr, req) - body := rr.Body.String() - for _, want := range []string{ - "识别模板列表", - "std_workshop_face_recognition_shoe_alarm", - "helmet", - "模板详情", - "helmet template", - `data-nav-row`, - `data-nav-href="/ui/assets/templates?name=helmet"`, - `class="selected"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected template assets page to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{ + "识别模板列表", + "std_workshop_face_recognition_shoe_alarm", + "helmet", + "模板详情", + "helmet template", + `data-nav-row`, + `data-nav-href="/ui/assets/templates?name=helmet"`, + `class="selected"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected template assets page to contain %q, got:\n%s", want, body) + } + } } func TestUI_OverlayAssetsPageShowsListAndSelectedDetail(t *testing.T) { - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"启用人脸识别和陌生候选调试日志,用于联调和测试。","instance_overrides":{"*":{"override":{}}}}`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"description":"夜间场景下放宽识别约束,减少低照环境漏检。","instance_overrides":{"cam2":{"override":{}}}}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[{}],"edges":[]}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`) + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"}}]}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"启用人脸识别和陌生候选调试日志,用于联调和测试。","instance_overrides":{"*":{"override":{}}}}`) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"description":"夜间场景下放宽识别约束,减少低照环境漏检。","instance_overrides":{"cam2":{"override":{}}}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[{}],"edges":[]}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/overlays?name=night_relaxed", nil) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ui/assets/overlays?name=night_relaxed", nil) + rr := httptest.NewRecorder() - ui.pageAssetOverlays(rr, req) + ui.pageAssetOverlays(rr, req) - body := rr.Body.String() - for _, want := range []string{ - "调试参数列表", - "face_debug", - "night_relaxed", - "夜间场景下放宽识别约束,减少低照环境漏检。", - "cam2", - `data-nav-row`, - `data-nav-href="/ui/assets/overlays?name=night_relaxed"`, - `class="selected"`, - } { - if !strings.Contains(body, want) { - t.Fatalf("expected overlay assets page to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{ + "调试参数列表", + "face_debug", + "night_relaxed", + "夜间场景下放宽识别约束,减少低照环境漏检。", + "cam2", + `data-nav-row`, + `data-nav-href="/ui/assets/overlays?name=night_relaxed"`, + `class="selected"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected overlay assets page to contain %q, got:\n%s", want, body) + } + } } func TestUI_ProfileAssetsPageShowsListAndSelectedEditor(t *testing.T) { - t.Skip("legacy profile assets page replaced by scene-template page") - ui := newTestUI(t) - root := t.TempDir() - writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ + t.Skip("legacy profile assets page replaced by scene-template page") + ui := newTestUI(t) + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{ "name":"local_3588_test", "business_name":"A厂区视觉识别", "description":"test profile", "queue":{"size":8,"strategy":"drop_oldest"}, "instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}] }`) - writeTestFile(t, filepath.Join(root, "configs", "profiles", "night_shift.json"), `{ + writeTestFile(t, filepath.Join(root, "configs", "profiles", "night_shift.json"), `{ "name":"night_shift", "business_name":"夜班巡检", "description":"night profile", "queue":{"size":4,"strategy":"drop_oldest"}, "instances":[{"name":"cam9","template":"std_workshop_face_recognition_shoe_alarm","scene_meta":{"display_name":"西门"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_09"}}}] }`) - writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}]},"template":{"nodes":[{}],"edges":[]}}`) - ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) - mustImportAssetsForUI(t, ui.preview) - mustImportAssetsForUI(t, ui.preview) + writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + mustSaveTemplate(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","slots":{"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}]},"template":{"nodes":[{}],"edges":[]}}`) + ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + mustImportAssetsForUI(t, ui.preview) + mustImportAssetsForUI(t, ui.preview) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles?name=night_shift", nil) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ui/assets/profiles?name=night_shift", nil) + rr := httptest.NewRecorder() - ui.pageAssetProfiles(rr, req) + ui.pageAssetProfiles(rr, req) - body := rr.Body.String() - for _, want := range []string{"场景配置列表", "local_3588_test", "night_shift", "场景配置", "夜班巡检", "night profile", "西门", "gate_cam_09"} { - if !strings.Contains(body, want) { - t.Fatalf("expected profile assets page to contain %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{"场景配置列表", "local_3588_test", "night_shift", "场景配置", "夜班巡检", "night profile", "西门", "gate_cam_09"} { + if !strings.Contains(body, want) { + t.Fatalf("expected profile assets page to contain %q, got:\n%s", want, body) + } + } } func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) { - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - reg := service.NewRegistryService(cfg, nil) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"}) - reg.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.1", BuildID: "20260419.151900", GitSha: "8eaf213"}) - tasks := service.NewTaskService(cfg, nil, reg) - taskConfig, err := tasks.CreateTask("config_apply", []string{"edge-01", "edge-02"}, map[string]any{"config": map[string]any{}}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - taskService, err := tasks.CreateTask("media_restart", []string{"edge-01", "edge-02"}, nil) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - taskConfig.Mu.Lock() - taskConfig.Status = models.TaskSuccess - for _, did := range taskConfig.DeviceIDs { - if ds, ok := taskConfig.Devices[did]; ok && ds != nil { - ds.Status = models.TaskSuccess - } - } - taskConfig.Mu.Unlock() - taskService.Mu.Lock() - taskService.Status = models.TaskSuccess - for _, did := range taskService.DeviceIDs { - if ds, ok := taskService.Devices[did]; ok && ds != nil { - ds.Status = models.TaskSuccess - } - } - taskService.Mu.Unlock() - ui, err := NewUI(nil, reg, nil, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + reg := service.NewRegistryService(cfg, nil) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0", BuildID: "20260419.151628", GitSha: "5c04681"}) + reg.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.1", BuildID: "20260419.151900", GitSha: "8eaf213"}) + tasks := service.NewTaskService(cfg, nil, reg) + taskConfig, err := tasks.CreateTask("config_apply", []string{"edge-01", "edge-02"}, map[string]any{"config": map[string]any{}}) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + taskService, err := tasks.CreateTask("media_restart", []string{"edge-01", "edge-02"}, nil) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + taskConfig.Mu.Lock() + taskConfig.Status = models.TaskSuccess + for _, did := range taskConfig.DeviceIDs { + if ds, ok := taskConfig.Devices[did]; ok && ds != nil { + ds.Status = models.TaskSuccess + } + } + taskConfig.Mu.Unlock() + taskService.Mu.Lock() + taskService.Status = models.TaskSuccess + for _, did := range taskService.DeviceIDs { + if ds, ok := taskService.Devices[did]; ok && ds != nil { + ds.Status = models.TaskSuccess + } + } + taskService.Mu.Unlock() + ui, err := NewUI(nil, reg, nil, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } - rrAudit := httptest.NewRecorder() - ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil)) - for _, want := range []string{"系统管理 / 日志审计 / 审计记录", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发设备分配", "重启视频分析服务"} { - if !strings.Contains(rrAudit.Body.String(), want) { - t.Fatalf("expected audit HTML to contain %q", want) - } - } - for _, forbidden := range []string{"框架版", "后续", "节点执行情况", `disabled`} { - if strings.Contains(rrAudit.Body.String(), forbidden) { - t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden) - } - } - for _, forbidden := range []string{"success", "failed", "running"} { - if strings.Contains(rrAudit.Body.String(), forbidden) { - t.Fatalf("audit HTML should not leak raw status enum %q", forbidden) - } - } + rrAudit := httptest.NewRecorder() + ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil)) + for _, want := range []string{"系统管理 / 日志审计 / 审计记录", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发设备分配", "重启视频分析服务"} { + if !strings.Contains(rrAudit.Body.String(), want) { + t.Fatalf("expected audit HTML to contain %q", want) + } + } + for _, forbidden := range []string{"框架版", "后续", "节点执行情况", `disabled`} { + if strings.Contains(rrAudit.Body.String(), forbidden) { + t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden) + } + } + for _, forbidden := range []string{"success", "failed", "running"} { + if strings.Contains(rrAudit.Body.String(), forbidden) { + t.Fatalf("audit HTML should not leak raw status enum %q", forbidden) + } + } - rrTasks := httptest.NewRecorder() - ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil)) - for _, want := range []string{"执行历史", "目标设备数"} { - if !strings.Contains(rrTasks.Body.String(), want) { - t.Fatalf("expected tasks HTML to contain %q", want) - } - } - for _, forbidden := range []string{"节点执行情况", "创建任务", "

目标设备

", "高级参数(JSON)", `name="device_ids"`, `name="payload_json"`} { - if strings.Contains(rrTasks.Body.String(), forbidden) { - t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden) - } - } + rrTasks := httptest.NewRecorder() + ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil)) + for _, want := range []string{"执行历史", "目标设备数"} { + if !strings.Contains(rrTasks.Body.String(), want) { + t.Fatalf("expected tasks HTML to contain %q", want) + } + } + for _, forbidden := range []string{"节点执行情况", "创建任务", "

目标设备

", "高级参数(JSON)", `name="device_ids"`, `name="payload_json"`} { + if strings.Contains(rrTasks.Body.String(), forbidden) { + t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden) + } + } - rrTaskConfig := httptest.NewRecorder() - reqTask := httptest.NewRequest(http.MethodGet, "/ui/tasks/"+taskConfig.ID, nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("id", taskConfig.ID) - reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx)) - ui.pageTask(rrTaskConfig, reqTask) - for _, want := range []string{"任务概览", "下发设备分配", "返回任务列表"} { - if !strings.Contains(rrTaskConfig.Body.String(), want) { - t.Fatalf("expected task detail HTML to contain %q", want) - } - } - for _, forbidden := range []string{"返回操作审计"} { - if strings.Contains(rrTaskConfig.Body.String(), forbidden) { - t.Fatalf("task detail HTML should not contain %q", forbidden) - } - } + rrTaskConfig := httptest.NewRecorder() + reqTask := httptest.NewRequest(http.MethodGet, "/ui/tasks/"+taskConfig.ID, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", taskConfig.ID) + reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx)) + ui.pageTask(rrTaskConfig, reqTask) + for _, want := range []string{"任务概览", "下发设备分配", "返回任务列表"} { + if !strings.Contains(rrTaskConfig.Body.String(), want) { + t.Fatalf("expected task detail HTML to contain %q", want) + } + } + for _, forbidden := range []string{"返回操作审计"} { + if strings.Contains(rrTaskConfig.Body.String(), forbidden) { + t.Fatalf("task detail HTML should not contain %q", forbidden) + } + } - rrSystem := httptest.NewRecorder() - ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil)) - for _, want := range []string{"服务状态", "数据备份", "数据恢复", "/health", "/openapi.json"} { - if !strings.Contains(rrSystem.Body.String(), want) { - t.Fatalf("expected system HTML to contain %q", want) - } - } - for _, forbidden := range []string{"框架版", "后续", "平台状态与访问入口"} { - if strings.Contains(rrSystem.Body.String(), forbidden) { - t.Fatalf("system HTML should not contain placeholder marker %q", forbidden) - } - } + rrSystem := httptest.NewRecorder() + ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil)) + for _, want := range []string{"服务状态", "数据备份", "数据恢复", "/health", "/openapi.json"} { + if !strings.Contains(rrSystem.Body.String(), want) { + t.Fatalf("expected system HTML to contain %q", want) + } + } + for _, forbidden := range []string{"框架版", "后续", "平台状态与访问入口"} { + if strings.Contains(rrSystem.Body.String(), forbidden) { + t.Fatalf("system HTML should not contain placeholder marker %q", forbidden) + } + } } func TestUI_DeviceAssignmentsPageShowsBoard(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ - SceneTemplateName: "scene_a", - Name: "cam1", - DisplayName: "东门入口", - VideoSourceRef: "vs1", - OutputChannel: "cam1", - RTSPPort: "8555", - BodyJSON: `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, - }); err != nil { - t.Fatalf("SaveRecognitionUnit cam1: %v", err) - } - if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ - SceneTemplateName: "scene_a", - Name: "cam2", - DisplayName: "西门入口", - VideoSourceRef: "vs2", - OutputChannel: "cam2", - RTSPPort: "8555", - BodyJSON: `{"name":"cam2","scene_meta":{"display_name":"西门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`, - }); err != nil { - t.Fatalf("SaveRecognitionUnit cam2: %v", err) - } - if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil { - t.Fatalf("SaveDeviceAssignment: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ + SceneTemplateName: "scene_a", + Name: "cam1", + DisplayName: "东门入口", + VideoSourceRef: "vs1", + OutputChannel: "cam1", + RTSPPort: "8555", + BodyJSON: `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, + }); err != nil { + t.Fatalf("SaveRecognitionUnit cam1: %v", err) + } + if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ + SceneTemplateName: "scene_a", + Name: "cam2", + DisplayName: "西门入口", + VideoSourceRef: "vs2", + OutputChannel: "cam2", + RTSPPort: "8555", + BodyJSON: `{"name":"cam2","scene_meta":{"display_name":"西门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`, + }); err != nil { + t.Fatalf("SaveRecognitionUnit cam2: %v", err) + } + if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil { + t.Fatalf("SaveDeviceAssignment: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments", nil) - rr := httptest.NewRecorder() - ui.pageDeviceAssignments(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments", nil) + rr := httptest.NewRecorder() + ui.pageDeviceAssignments(rr, req) - body := rr.Body.String() - for _, want := range []string{"通道部署", "视频通道", "未分配", "每台最多", "自动平均分配", "保存通道部署", "东门入口", "西门入口"} { - if !strings.Contains(body, want) { - t.Fatalf("expected device assignment board to contain %q, got:\n%s", want, body) - } - } - for _, want := range []string{"测试设备 03", "测试设备 08"} { - if !strings.Contains(body, want) { - t.Fatalf("expected device assignment board to include preview device %q, got:\n%s", want, body) - } - } + body := rr.Body.String() + for _, want := range []string{"通道部署", "视频通道", "未分配", "每台最多", "自动平均分配", "保存通道部署", "东门入口", "西门入口"} { + if !strings.Contains(body, want) { + t.Fatalf("expected device assignment board to contain %q, got:\n%s", want, body) + } + } + for _, want := range []string{"测试设备 03", "测试设备 08"} { + if !strings.Contains(body, want) { + t.Fatalf("expected device assignment board to include preview device %q, got:\n%s", want, body) + } + } } func TestUI_DeviceAssignmentsPageHidesAddControlsForFullDevice(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ - SceneTemplateName: "scene_a", - Name: "cam1", - DisplayName: "东门入口", - VideoSourceRef: "vs1", - OutputChannel: "cam1", - RTSPPort: "8555", - BodyJSON: `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, - }); err != nil { - t.Fatalf("SaveRecognitionUnit cam1: %v", err) - } - if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil { - t.Fatalf("SaveDeviceAssignment: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ + SceneTemplateName: "scene_a", + Name: "cam1", + DisplayName: "东门入口", + VideoSourceRef: "vs1", + OutputChannel: "cam1", + RTSPPort: "8555", + BodyJSON: `{"name":"cam1","scene_meta":{"display_name":"东门入口"},"input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, + }); err != nil { + t.Fatalf("SaveRecognitionUnit cam1: %v", err) + } + if err := repo.SaveDeviceAssignment("edge-01", "scene_a", "", `{"device_id":"edge-01","profile_name":"scene_a","recognition_units":["scene_a::cam1"]}`); err != nil { + t.Fatalf("SaveDeviceAssignment: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments?max_units_per_device=1", nil) - rr := httptest.NewRecorder() - ui.pageDeviceAssignments(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/device-assignments?max_units_per_device=1", nil) + rr := httptest.NewRecorder() + ui.pageDeviceAssignments(rr, req) - body := rr.Body.String() - start := strings.Index(body, `data-device-card="edge-01"`) - if start < 0 { - t.Fatalf("expected edge-01 card in html: %s", body) - } - end := strings.Index(body[start:], ``) - if end < 0 { - t.Fatalf("expected end of edge-01 card in html: %s", body) - } - cardHTML := body[start : start+end] - if strings.Contains(cardHTML, "添加识别单元") { - t.Fatalf("expected full device card to hide add controls, got:\n%s", cardHTML) - } + body := rr.Body.String() + start := strings.Index(body, `data-device-card="edge-01"`) + if start < 0 { + t.Fatalf("expected edge-01 card in html: %s", body) + } + end := strings.Index(body[start:], ``) + if end < 0 { + t.Fatalf("expected end of edge-01 card in html: %s", body) + } + cardHTML := body[start : start+end] + if strings.Contains(cardHTML, "添加识别单元") { + t.Fatalf("expected full device card to hide add controls, got:\n%s", cardHTML) + } } func TestUI_ActionDeviceAssignmentSavePersistsBoardState(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ - SceneTemplateName: "scene_a", - Name: "cam1", - VideoSourceRef: "vs1", - OutputChannel: "cam1", - RTSPPort: "8555", - BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, - }); err != nil { - t.Fatalf("SaveRecognitionUnit cam1: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ + SceneTemplateName: "scene_a", + Name: "cam1", + VideoSourceRef: "vs1", + OutputChannel: "cam1", + RTSPPort: "8555", + BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, + }); err != nil { + t.Fatalf("SaveRecognitionUnit cam1: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - form := url.Values{} - form.Set("max_units_per_device", "4") - form.Set("board_state_json", `{"devices":{"edge-01":["scene_a::cam1"]}}`) - req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("max_units_per_device", "4") + form.Set("board_state_json", `{"devices":{"edge-01":["scene_a::cam1"]}}`) + req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionDeviceAssignmentSave(rr, req) + ui.actionDeviceAssignmentSave(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - item, err := ui.preview.GetDeviceAssignment("edge-01") - if err != nil { - t.Fatalf("GetDeviceAssignment: %v", err) - } - if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 1 || item.RecognitionUnits[0] != "scene_a::cam1" { - t.Fatalf("unexpected saved assignment: %#v", item) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + item, err := ui.preview.GetDeviceAssignment("edge-01") + if err != nil { + t.Fatalf("GetDeviceAssignment: %v", err) + } + if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 1 || item.RecognitionUnits[0] != "scene_a::cam1" { + t.Fatalf("unexpected saved assignment: %#v", item) + } } func TestUI_ActionDeviceAssignmentSaveIgnoresPreviewDevices(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ - SceneTemplateName: "scene_a", - Name: "cam1", - VideoSourceRef: "vs1", - OutputChannel: "cam1", - RTSPPort: "8555", - BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, - }); err != nil { - t.Fatalf("SaveRecognitionUnit cam1: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{ + SceneTemplateName: "scene_a", + Name: "cam1", + VideoSourceRef: "vs1", + OutputChannel: "cam1", + RTSPPort: "8555", + BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`, + }); err != nil { + t.Fatalf("SaveRecognitionUnit cam1: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - form := url.Values{} - form.Set("max_units_per_device", "4") - form.Set("board_state_json", `{"devices":{"demo-edge-02":["scene_a::cam1"]}}`) - req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("max_units_per_device", "4") + form.Set("board_state_json", `{"devices":{"demo-edge-02":["scene_a::cam1"]}}`) + req := httptest.NewRequest(http.MethodPost, "/ui/device-assignments", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionDeviceAssignmentSave(rr, req) + ui.actionDeviceAssignmentSave(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - item, err := ui.preview.GetDeviceAssignment("demo-edge-02") - if err == nil && item != nil { - t.Fatalf("expected preview device assignment to be ignored, got %#v", item) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + item, err := ui.preview.GetDeviceAssignment("demo-edge-02") + if err == nil && item != nil { + t.Fatalf("expected preview device assignment to be ignored, got %#v", item) + } } func TestUI_DeviceAssignmentsBoardUsesDenseGridLayout(t *testing.T) { - css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css")) - if err != nil { - t.Fatalf("ReadFile style.css: %v", err) - } - if !strings.Contains(string(css), ".assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));") { - t.Fatalf("expected dense four-column assignment board grid, got:\n%s", string(css)) - } + css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css")) + if err != nil { + t.Fatalf("ReadFile style.css: %v", err) + } + if !strings.Contains(string(css), ".assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));") { + t.Fatalf("expected dense four-column assignment board grid, got:\n%s", string(css)) + } } func TestUI_DeviceAssignmentsBoardUsesBrightOrangeLoadStates(t *testing.T) { - css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css")) - if err != nil { - t.Fatalf("ReadFile style.css: %v", err) - } - text := string(css) - for _, want := range []string{ - "--assignment-low-bg:#24190d;", - "--assignment-busy-bg:#3a2812;", - "--assignment-full-bg:#4b3110;", - "--assignment-low-bg:#f4ead8;", - "--assignment-busy-bg:#f0dcc0;", - "--assignment-full-bg:#eccb96;", - "--assignment-low-bg:#332514;", - "--assignment-busy-bg:#3f3015;", - "--assignment-full-bg:#57401a;", - } { - if !strings.Contains(text, want) { - t.Fatalf("expected brighter orange assignment state token %q in css", want) - } - } + css, err := os.ReadFile(filepath.Join("ui", "assets", "style.css")) + if err != nil { + t.Fatalf("ReadFile style.css: %v", err) + } + text := string(css) + for _, want := range []string{ + "--assignment-low-bg:#24190d;", + "--assignment-busy-bg:#3a2812;", + "--assignment-full-bg:#4b3110;", + "--assignment-low-bg:#f4ead8;", + "--assignment-busy-bg:#f0dcc0;", + "--assignment-full-bg:#eccb96;", + "--assignment-low-bg:#332514;", + "--assignment-busy-bg:#3f3015;", + "--assignment-full-bg:#57401a;", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected brighter orange assignment state token %q in css", want) + } + } } func TestUI_SidebarMatchesApprovedIoTArchitecture(t *testing.T) { - t.Skip("sidebar expectations updated by new IA; replace with dedicated scene-template/unit/assignment assertions") - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/devices") + t.Skip("sidebar expectations updated by new IA; replace with dedicated scene-template/unit/assignment assertions") + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/devices") - for _, label := range []string{"总览", "设备", "场景配置", "基础配置", "任务", "系统管理", "模型管理", "资源管理", "日志审计", "系统状态"} { - if !strings.Contains(html, label) { - t.Fatalf("expected sidebar label %q in html: %s", label, html) - } - } - for _, removed := range []string{"配置管理", "操作审计", `
`} { - if strings.Contains(html, removed) { - t.Fatalf("did not expect legacy top-level label %q in html: %s", removed, html) - } - } + for _, label := range []string{"总览", "设备", "场景配置", "基础配置", "任务", "系统管理", "模型管理", "资源管理", "日志审计", "系统状态"} { + if !strings.Contains(html, label) { + t.Fatalf("expected sidebar label %q in html: %s", label, html) + } + } + for _, removed := range []string{"配置管理", "操作审计", ``} { + if strings.Contains(html, removed) { + t.Fatalf("did not expect legacy top-level label %q in html: %s", removed, html) + } + } } func TestUI_DashboardShowsGlobalOperationsSummary(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/dashboard") + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/dashboard") - for _, text := range []string{"在线率", "最近任务", "异常设备", "设备总数"} { - if !strings.Contains(html, text) { - t.Fatalf("expected dashboard text %q in html: %s", text, html) - } - } + for _, text := range []string{"在线率", "最近任务", "异常设备", "设备总数"} { + if !strings.Contains(html, text) { + t.Fatalf("expected dashboard text %q in html: %s", text, html) + } + } } func TestUI_DashboardOmitsRedundantExplanatoryChrome(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/dashboard") + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/dashboard") - for _, text := range []string{ - "只看 fleet 级运行态势、最近任务和需要关注的异常设备。", - "当前版本基于节点在线状态和视频分析服务查询入口汇总。", - "按常见现场操作顺序进入对应页面。", - "视频分析概览", - "运维工作流", - "快速入口", - "任务健康", - "失败任务", - "成功任务", - `设备列表`, - `任务`, - } { - if strings.Contains(html, text) { - t.Fatalf("dashboard should omit redundant chrome %q in html: %s", text, html) - } - } + for _, text := range []string{ + "只看 fleet 级运行态势、最近任务和需要关注的异常设备。", + "当前版本基于节点在线状态和视频分析服务查询入口汇总。", + "按常见现场操作顺序进入对应页面。", + "视频分析概览", + "运维工作流", + "快速入口", + "任务健康", + "失败任务", + "成功任务", + `设备列表`, + `任务`, + } { + if strings.Contains(html, text) { + t.Fatalf("dashboard should omit redundant chrome %q in html: %s", text, html) + } + } } func TestUI_TasksPageOwnsBatchExecutionDomain(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/tasks") + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/tasks") - for _, text := range []string{"执行历史", "目标设备数"} { - if !strings.Contains(html, text) { - t.Fatalf("expected tasks text %q in html: %s", text, html) - } - } + for _, text := range []string{"执行历史", "目标设备数"} { + if !strings.Contains(html, text) { + t.Fatalf("expected tasks text %q in html: %s", text, html) + } + } } func TestUI_LogAuditPageOwnsLogsAndAudit(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/diagnostics") + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/diagnostics") - for _, text := range []string{"日志", "审计"} { - if !strings.Contains(html, text) { - t.Fatalf("expected diagnostics text %q in html: %s", text, html) - } - } - if strings.Contains(html, "进入系统状态") { - t.Fatalf("log audit page should not include system status: %s", html) - } + for _, text := range []string{"日志", "审计"} { + if !strings.Contains(html, text) { + t.Fatalf("expected diagnostics text %q in html: %s", text, html) + } + } + if strings.Contains(html, "进入系统状态") { + t.Fatalf("log audit page should not include system status: %s", html) + } } func TestUI_DeviceDetailActsAsSingleDeviceWorkspace(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/devices/edge-01") + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/devices/edge-01") - for _, text := range []string{"概览", "运行与服务", "设备分配", "日志与指标"} { - if !strings.Contains(html, text) { - t.Fatalf("expected device workspace text %q in html: %s", text, html) - } - } - if strings.Contains(html, "进入配置管理") { - t.Fatalf("device detail should not contain cross-module config shortcut: %s", html) - } + for _, text := range []string{"概览", "运行与服务", "设备分配", "日志与指标"} { + if !strings.Contains(html, text) { + t.Fatalf("expected device workspace text %q in html: %s", text, html) + } + } + if strings.Contains(html, "进入配置管理") { + t.Fatalf("device detail should not contain cross-module config shortcut: %s", html) + } } func TestUI_DeviceWorkspaceContainsSingleDeviceOperations(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/devices/edge-01") + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/devices/edge-01") - for _, text := range []string{"概览", "运行与服务", "设备分配", "模型与资源", "日志与指标"} { - if !strings.Contains(html, text) { - t.Fatalf("expected device workspace section %q in html: %s", text, html) - } - } - for _, removed := range []string{"返回设备选择", "配置管理面向单台设备"} { - if strings.Contains(html, removed) { - t.Fatalf("did not expect legacy standalone config flow text %q in html: %s", removed, html) - } - } + for _, text := range []string{"概览", "运行与服务", "设备分配", "模型与资源", "日志与指标"} { + if !strings.Contains(html, text) { + t.Fatalf("expected device workspace section %q in html: %s", text, html) + } + } + for _, removed := range []string{"返回设备选择", "配置管理面向单台设备"} { + if strings.Contains(html, removed) { + t.Fatalf("did not expect legacy standalone config flow text %q in html: %s", removed, html) + } + } } func TestUI_TaskDetailPresentsExecutionSections(t *testing.T) { - ui := newTestUI(t) - task, err := ui.tasks.CreateTask("config_apply", []string{"edge-01"}, map[string]any{"config": map[string]any{}}) - if err != nil { - t.Fatalf("create task: %v", err) - } + ui := newTestUI(t) + task, err := ui.tasks.CreateTask("config_apply", []string{"edge-01"}, map[string]any{"config": map[string]any{}}) + if err != nil { + t.Fatalf("create task: %v", err) + } - html := renderPage(t, ui, "/ui/tasks/"+task.ID) - for _, text := range []string{"任务概览", "执行进度", "设备结果"} { - if !strings.Contains(html, text) { - t.Fatalf("expected task detail section %q in html: %s", text, html) - } - } + html := renderPage(t, ui, "/ui/tasks/"+task.ID) + for _, text := range []string{"任务概览", "执行进度", "设备结果"} { + if !strings.Contains(html, text) { + t.Fatalf("expected task detail section %q in html: %s", text, html) + } + } } func TestUI_SystemManagementPagesUseSystemManagementCrumb(t *testing.T) { - ui := newTestUI(t) + ui := newTestUI(t) - for _, item := range []struct { - path string - want string - }{ - {path: "/ui/system", want: "服务状态"}, - {path: "/ui/audit", want: "系统管理 / 日志审计 / 审计记录"}, - {path: "/ui/api", want: "系统管理 / 日志审计 / 高级调试"}, - } { - html := renderPage(t, ui, item.path) - if !strings.Contains(html, item.want) { - t.Fatalf("expected %s to contain crumb %q, got: %s", item.path, item.want, html) - } - } + for _, item := range []struct { + path string + want string + }{ + {path: "/ui/system", want: "服务状态"}, + {path: "/ui/audit", want: "系统管理 / 日志审计 / 审计记录"}, + {path: "/ui/api", want: "系统管理 / 日志审计 / 高级调试"}, + } { + html := renderPage(t, ui, item.path) + if !strings.Contains(html, item.want) { + t.Fatalf("expected %s to contain crumb %q, got: %s", item.path, item.want, html) + } + } } func TestUI_TasksPageDoesNotExposeTaskCreationForm(t *testing.T) { - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - html := renderPage(t, ui, "/ui/tasks?selected=edge-02") - for _, forbidden := range []string{"

目标设备

", `name="device_id"`, `name="device_ids"`, `name="payload_json"`, "创建任务", ""} { - if strings.Contains(html, forbidden) { - t.Fatalf("tasks page should not expose task-creation UI %q, got: %s", forbidden, html) - } - } + html := renderPage(t, ui, "/ui/tasks?selected=edge-02") + for _, forbidden := range []string{"

目标设备

", `name="device_id"`, `name="device_ids"`, `name="payload_json"`, "创建任务", ""} { + if strings.Contains(html, forbidden) { + t.Fatalf("tasks page should not expose task-creation UI %q, got: %s", forbidden, html) + } + } } func TestUI_TasksPageDoesNotShowTransitionQuickActions(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/tasks") - for _, forbidden := range []string{"常用动作", "批量下发", "批量重启", "批量回滚"} { - if strings.Contains(html, forbidden) { - t.Fatalf("tasks page should not contain transition quick action block %q, got: %s", forbidden, html) - } - } + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/tasks") + for _, forbidden := range []string{"常用动作", "批量下发", "批量重启", "批量回滚"} { + if strings.Contains(html, forbidden) { + t.Fatalf("tasks page should not contain transition quick action block %q, got: %s", forbidden, html) + } + } } func TestUI_ActionCreateTaskUsesSelectedDeviceCheckboxes(t *testing.T) { - ui := newTestUI(t) - ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) + ui := newTestUI(t) + ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true}) - form := url.Values{} - form.Set("type", "media_restart") - form.Add("device_id", "edge-01") - form.Add("device_id", "edge-02") - form.Set("payload_json", `{}`) - req := httptest.NewRequest(http.MethodPost, "/ui/tasks", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("type", "media_restart") + form.Add("device_id", "edge-01") + form.Add("device_id", "edge-02") + form.Set("payload_json", `{}`) + req := httptest.NewRequest(http.MethodPost, "/ui/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - ui.actionCreateTask(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } + ui.actionCreateTask(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } - loc := rr.Header().Get("Location") - taskID := strings.TrimPrefix(loc, "/ui/tasks/") - var created *models.Task - for _, item := range ui.tasks.ListTasks() { - if item.ID == taskID { - task := item - created = &task - break - } - } - if created == nil { - t.Fatalf("expected created task %q to exist", taskID) - } - if got := strings.Join(created.DeviceIDs, ","); got != "edge-01,edge-02" { - t.Fatalf("expected selected devices preserved in order, got %q", got) - } + loc := rr.Header().Get("Location") + taskID := strings.TrimPrefix(loc, "/ui/tasks/") + var created *models.Task + for _, item := range ui.tasks.ListTasks() { + if item.ID == taskID { + task := item + created = &task + break + } + } + if created == nil { + t.Fatalf("expected created task %q to exist", taskID) + } + if got := strings.Join(created.DeviceIDs, ","); got != "edge-01,edge-02" { + t.Fatalf("expected selected devices preserved in order, got %q", got) + } } func TestUI_TasksPageShowsPersistedHistory(t *testing.T) { - cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} - reg := service.NewRegistryService(cfg, nil) - reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true}) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewTasksRepo(store.DB()) + cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000} + reg := service.NewRegistryService(cfg, nil) + reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true}) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewTasksRepo(store.DB()) - task := models.NewTask("task-persisted", "reload", []string{"edge-01"}, nil) - task.Status = models.TaskSuccess - task.Devices["edge-01"].Status = models.TaskSuccess - task.Devices["edge-01"].Progress = 1 - if err := repo.Save(task); err != nil { - t.Fatalf("Save: %v", err) - } + task := models.NewTask("task-persisted", "reload", []string{"edge-01"}, nil) + task.Status = models.TaskSuccess + task.Devices["edge-01"].Status = models.TaskSuccess + task.Devices["edge-01"].Progress = 1 + if err := repo.Save(task); err != nil { + t.Fatalf("Save: %v", err) + } - tasks := service.NewTaskService(cfg, nil, reg, repo) - if err := tasks.LoadPersistedTasks(); err != nil { - t.Fatalf("LoadPersistedTasks: %v", err) - } + tasks := service.NewTaskService(cfg, nil, reg, repo) + if err := tasks.LoadPersistedTasks(); err != nil { + t.Fatalf("LoadPersistedTasks: %v", err) + } - ui, err := NewUI(nil, reg, nil, tasks, nil) - if err != nil { - t.Fatalf("NewUI: %v", err) - } - html := renderPage(t, ui, "/ui/tasks") - for _, want := range []string{"task-persisted", "重载识别服务", "1 台"} { - if !strings.Contains(html, want) { - t.Fatalf("expected persisted task history to contain %q, got: %s", want, html) - } - } + ui, err := NewUI(nil, reg, nil, tasks, nil) + if err != nil { + t.Fatalf("NewUI: %v", err) + } + html := renderPage(t, ui, "/ui/tasks") + for _, want := range []string{"task-persisted", "重载识别服务", "1 台"} { + if !strings.Contains(html, want) { + t.Fatalf("expected persisted task history to contain %q, got: %s", want, html) + } + } } func TestUI_AssetsOverviewOmitsLegacyImportAction(t *testing.T) { - ui := newTestUI(t) - html := renderPage(t, ui, "/ui/assets") - for _, forbidden := range []string{"资产操作", "导入现有 JSON", `action="/ui/assets/import"`} { - if strings.Contains(html, forbidden) { - t.Fatalf("expected assets overview to omit legacy import action %q, got: %s", forbidden, html) - } - } + ui := newTestUI(t) + html := renderPage(t, ui, "/ui/assets") + for _, forbidden := range []string{"资产操作", "导入现有 JSON", `action="/ui/assets/import"`} { + if strings.Contains(html, forbidden) { + t.Fatalf("expected assets overview to omit legacy import action %q, got: %s", forbidden, html) + } + } } func TestUI_AssetTemplateExportsJSON(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n" - if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + const raw = "{\n \"name\": \"helmet\",\n \"template\": {\n \"nodes\": [],\n \"edges\": []\n }\n}\n" + if err := repo.SaveTemplate("helmet", "helmet template", raw); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } - ui := newTestUI(t) - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates/helmet/export", nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("name", "helmet") - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/ui/assets/templates/helmet/export", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", "helmet") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() - ui.pageAssetTemplateExport(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "helmet.json") { - t.Fatalf("expected attachment filename, got %q", got) - } - if rr.Body.String() != raw { - t.Fatalf("unexpected export body: %s", rr.Body.String()) - } + ui.pageAssetTemplateExport(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "helmet.json") { + t.Fatalf("expected attachment filename, got %q", got) + } + if rr.Body.String() != raw { + t.Fatalf("unexpected export body: %s", rr.Body.String()) + } } func TestUI_AssetTemplateShowsSaveAsExportButton(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } - ui := newTestUI(t) - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - html := renderPage(t, ui, "/ui/assets/templates?name=helmet") + ui := newTestUI(t) + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + html := renderPage(t, ui, "/ui/assets/templates?name=helmet") - for _, want := range []string{"导出为 JSON", `data-export-url="/ui/assets/templates/helmet/export"`, `data-default-filename="helmet.json"`, "showSaveFilePicker", "当前浏览器不支持选择保存目录和文件名"} { - if !strings.Contains(html, want) { - t.Fatalf("expected asset template html to contain %q, got: %s", want, html) - } - } + for _, want := range []string{"导出为 JSON", `data-export-url="/ui/assets/templates/helmet/export"`, `data-default-filename="helmet.json"`, "showSaveFilePicker", "当前浏览器不支持选择保存目录和文件名"} { + if !strings.Contains(html, want) { + t.Fatalf("expected asset template html to contain %q, got: %s", want, html) + } + } } func TestUI_AssetTemplatesPageShowsDirectStandardCloneAction(t *testing.T) { - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("std_face_recognition_stream", "标准人脸流模板", `{"name":"std_face_recognition_stream","description":"标准人脸流模板","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("std_face_recognition_stream", "标准人脸流模板", `{"name":"std_face_recognition_stream","description":"标准人脸流模板","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } - ui := newTestUI(t) - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream") + ui := newTestUI(t) + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream") - for _, want := range []string{"标准模板应作为基线", "复制标准模板", "模板详情"} { - if !strings.Contains(html, want) { - t.Fatalf("expected template page to contain %q, got: %s", want, html) - } - } - for _, forbidden := range []string{"新增模板", `name="clone_source" value="std_face_recognition_stream"`, "复制并编辑"} { - if strings.Contains(html, forbidden) { - t.Fatalf("expected template page to omit %q, got: %s", forbidden, html) - } - } + for _, want := range []string{"标准模板应作为基线", "复制标准模板", "模板详情"} { + if !strings.Contains(html, want) { + t.Fatalf("expected template page to contain %q, got: %s", want, html) + } + } + for _, forbidden := range []string{"新增模板", `name="clone_source" value="std_face_recognition_stream"`, "复制并编辑"} { + if strings.Contains(html, forbidden) { + t.Fatalf("expected template page to omit %q, got: %s", forbidden, html) + } + } } func TestUI_AssetTemplateGraphPageRendersEditorShell(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{ - "name": "helmet", - "description": "helmet template", - "template": { - "nodes": [{"id":"in","type":"input_rtsp","role":"source","enable":true}], - "edges": [] - } - }`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{ + "name": "helmet", + "description": "helmet template", + "template": { + "nodes": [{"id":"in","type":"input_rtsp","role":"source","enable":true}], + "edges": [] + } + }`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - html := renderPage(t, ui, "/ui/assets/templates/helmet/graph") - for _, want := range []string{ - "模板可视化编辑", - "graph-editor", - "graph_editor.css", - "graph_editor.js", - "graph-template-json", - `"id":"in"`, - `"type":"input_rtsp"`, - "graph-node-form", - `name="id"`, - `name="role"`, - `name="enable"`, - "graph-connect-target", - "graph-typed-param-fields", - "graph-auto-layout", - "graph-param-editor", - "graph-edge-form", - "graph-node-palette-list", - `data-catalog-url="/ui/api/graph-node-types"`, - `name="from"`, - `name="to"`, - "常用参数", - "高级 JSON", - "自动布局", - } { - if !strings.Contains(html, want) { - t.Fatalf("expected graph editor page to contain %q, got: %s", want, html) - } - } + html := renderPage(t, ui, "/ui/assets/templates/helmet/graph") + for _, want := range []string{ + "模板可视化编辑", + "graph-editor", + "graph_editor.css", + "graph_editor.js", + "graph-template-json", + `"id":"in"`, + `"type":"input_rtsp"`, + "graph-node-form", + `name="id"`, + `name="role"`, + `name="enable"`, + "graph-connect-target", + "graph-typed-param-fields", + "graph-auto-layout", + "graph-param-editor", + "graph-edge-form", + "graph-node-palette-list", + `data-catalog-url="/ui/api/graph-node-types"`, + `name="from"`, + `name="to"`, + "常用参数", + "高级 JSON", + "自动布局", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected graph editor page to contain %q, got: %s", want, html) + } + } } func TestUI_AssetTemplatePageShowsEditAndDeleteForUserTemplate(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - html := renderPage(t, ui, "/ui/assets/templates?name=helmet") - for _, want := range []string{`href="/ui/assets/templates?name=helmet&edit=1"`, `action="/ui/assets/templates/helmet/delete"`, "模板详情", "查看模式"} { - if !strings.Contains(html, want) { - t.Fatalf("expected template detail to contain %q, got: %s", want, html) - } - } + html := renderPage(t, ui, "/ui/assets/templates?name=helmet") + for _, want := range []string{`href="/ui/assets/templates?name=helmet&edit=1"`, `action="/ui/assets/templates/helmet/delete"`, "模板详情", "查看模式"} { + if !strings.Contains(html, want) { + t.Fatalf("expected template detail to contain %q, got: %s", want, html) + } + } } func TestUI_ActionAssetTemplateCloneCreatesDefaultCopyAndRedirectsToEdit(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("std_face_recognition_stream", "helmet template", `{"name":"std_face_recognition_stream","description":"helmet template","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","det"]]},"extra":{"mode":"std"}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("std_face_recognition_stream", "helmet template", `{"name":"std_face_recognition_stream","description":"helmet template","source":"standard","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","det"]]},"extra":{"mode":"std"}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/clone", nil) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/clone", nil) + rr := httptest.NewRecorder() - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - router.ServeHTTP(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=face_recognition_stream_copy&edit=1") { - t.Fatalf("expected edit redirect, got %q", got) - } - saved, err := repo.GetTemplate("face_recognition_stream_copy") - if err != nil { - t.Fatalf("GetTemplate: %v", err) - } - for _, want := range []string{`"name": "face_recognition_stream_copy"`, `"description": "helmet template"`, `"type": "input_rtsp"`, `"mode": "std"`} { - if saved == nil || !strings.Contains(saved.BodyJSON, want) { - t.Fatalf("expected cloned template to contain %q, got %#v", want, saved) - } - } + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=face_recognition_stream_copy&edit=1") { + t.Fatalf("expected edit redirect, got %q", got) + } + saved, err := repo.GetTemplate("face_recognition_stream_copy") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + for _, want := range []string{`"name": "face_recognition_stream_copy"`, `"description": "helmet template"`, `"type": "input_rtsp"`, `"mode": "std"`} { + if saved == nil || !strings.Contains(saved.BodyJSON, want) { + t.Fatalf("expected cloned template to contain %q, got %#v", want, saved) + } + } } func TestUI_GraphNodeTypesAPIFallbackListsCatalog(t *testing.T) { - ui := newTestUI(t) - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/graph-node-types", nil) - rr := httptest.NewRecorder() + ui := newTestUI(t) + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/api/graph-node-types", nil) + rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - var payload struct { - Items []graphNodeTypeInfo `json:"items"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { - t.Fatalf("invalid json: %v", err) - } - got := map[string]graphNodeTypeInfo{} - for _, item := range payload.Items { - got[item.Type] = item - } - for _, want := range []string{"input_rtsp", "ai_shoe_det", "event_fusion", "zlm_http"} { - item, ok := got[want] - if !ok { - t.Fatalf("expected graph node type %q in %#v", want, payload.Items) - } - if item.Label == "" || item.Icon == "" || item.Description == "" { - t.Fatalf("expected %q to include label, icon and description: %#v", want, item) - } - if item.Defaults["type"] != want { - t.Fatalf("expected %q defaults.type, got %#v", want, item.Defaults) - } - } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var payload struct { + Items []graphNodeTypeInfo `json:"items"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { + t.Fatalf("invalid json: %v", err) + } + got := map[string]graphNodeTypeInfo{} + for _, item := range payload.Items { + got[item.Type] = item + } + for _, want := range []string{"input_rtsp", "ai_shoe_det", "event_fusion", "zlm_http"} { + item, ok := got[want] + if !ok { + t.Fatalf("expected graph node type %q in %#v", want, payload.Items) + } + if item.Label == "" || item.Icon == "" || item.Description == "" { + t.Fatalf("expected %q to include label, icon and description: %#v", want, item) + } + if item.Defaults["type"] != want { + t.Fatalf("expected %q defaults.type, got %#v", want, item.Defaults) + } + } } func TestUI_AssetTemplateGraphSavePersistsNodeAndEdgeParameters(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - form := url.Values{} - form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source","enable":true,"url":"${slot:video_input_main.url}"},{"id":"det","type":"ai_yolo","role":"filter","enable":true,"conf":0.42}],"edges":[{"from":"in","to":"det","stream":"video"}]}}`) - req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source","enable":true,"url":"${slot:video_input_main.url}"},{"id":"det","type":"ai_yolo","role":"filter","enable":true,"conf":0.42}],"edges":[{"from":"in","to":"det","stream":"video"}]}}`) + req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - router.ServeHTTP(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - saved, err := repo.GetTemplate("helmet") - if err != nil { - t.Fatalf("GetTemplate: %v", err) - } - for _, want := range []string{`"url": "${slot:video_input_main.url}"`, `"conf": 0.42`, `"stream": "video"`} { - if saved == nil || !strings.Contains(saved.BodyJSON, want) { - t.Fatalf("expected saved graph parameter %q, got %#v", want, saved) - } - } + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + saved, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + for _, want := range []string{`"url": "${slot:video_input_main.url}"`, `"conf": 0.42`, `"stream": "video"`} { + if saved == nil || !strings.Contains(saved.BodyJSON, want) { + t.Fatalf("expected saved graph parameter %q, got %#v", want, saved) + } + } } func TestUI_AssetTemplateGraphSavePersistsLayout(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - form := url.Values{} - form.Set("json", `{"name":"helmet","description":"helmet template","ui":{"layout":{"version":1,"nodes":{"in":{"x":123,"y":456}}}},"template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`) - req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("json", `{"name":"helmet","description":"helmet template","ui":{"layout":{"version":1,"nodes":{"in":{"x":123,"y":456}}}},"template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`) + req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - router.ServeHTTP(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - saved, err := repo.GetTemplate("helmet") - if err != nil { - t.Fatalf("GetTemplate: %v", err) - } - if saved == nil || !strings.Contains(saved.BodyJSON, `"x": 123`) || !strings.Contains(saved.BodyJSON, `"y": 456`) { - t.Fatalf("expected saved layout, got %#v", saved) - } + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + saved, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if saved == nil || !strings.Contains(saved.BodyJSON, `"x": 123`) || !strings.Contains(saved.BodyJSON, `"y": 456`) { + t.Fatalf("expected saved layout, got %#v", saved) + } } func TestUI_AssetTemplateGraphSaveCanRenameTemplate(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - form := url.Values{} - form.Set("name", "helmet_v2") - form.Set("description", "helmet v2") - form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`) - req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("name", "helmet_v2") + form.Set("description", "helmet v2") + form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`) + req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - router.ServeHTTP(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates/helmet_v2/graph") { - t.Fatalf("expected renamed graph redirect, got %q", got) - } - oldRecord, err := repo.GetTemplate("helmet") - if err != nil { - t.Fatalf("GetTemplate old: %v", err) - } - if oldRecord != nil { - t.Fatalf("expected old template removed, got %#v", oldRecord) - } - newRecord, err := repo.GetTemplate("helmet_v2") - if err != nil { - t.Fatalf("GetTemplate new: %v", err) - } - if newRecord == nil || !strings.Contains(newRecord.BodyJSON, `"name": "helmet_v2"`) { - t.Fatalf("expected renamed template saved, got %#v", newRecord) - } - profile, err := repo.GetProfile("gate_a") - if err != nil { - t.Fatalf("GetProfile: %v", err) - } - if profile == nil || profile.TemplateName != "helmet_v2" { - t.Fatalf("expected profile ref updated, got %#v", profile) - } + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates/helmet_v2/graph") { + t.Fatalf("expected renamed graph redirect, got %q", got) + } + oldRecord, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate old: %v", err) + } + if oldRecord != nil { + t.Fatalf("expected old template removed, got %#v", oldRecord) + } + newRecord, err := repo.GetTemplate("helmet_v2") + if err != nil { + t.Fatalf("GetTemplate new: %v", err) + } + if newRecord == nil || !strings.Contains(newRecord.BodyJSON, `"name": "helmet_v2"`) { + t.Fatalf("expected renamed template saved, got %#v", newRecord) + } + profile, err := repo.GetProfile("gate_a") + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile == nil || profile.TemplateName != "helmet_v2" { + t.Fatalf("expected profile ref updated, got %#v", profile) + } } func TestUI_ActionAssetTemplateRenameFromDetailPage(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - form := url.Values{} - form.Set("name", "helmet_v2") - form.Set("description", "helmet v2") - req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/rename", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("name", "helmet_v2") + form.Set("description", "helmet v2") + req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/rename", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - router.ServeHTTP(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=helmet_v2") { - t.Fatalf("expected detail redirect, got %q", got) - } - record, err := repo.GetTemplate("helmet_v2") - if err != nil { - t.Fatalf("GetTemplate: %v", err) - } - if record == nil || record.Description != "helmet_v2" && record.Description != "helmet v2" { - t.Fatalf("expected renamed template, got %#v", record) - } + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=helmet_v2") { + t.Fatalf("expected detail redirect, got %q", got) + } + record, err := repo.GetTemplate("helmet_v2") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if record == nil || record.Description != "helmet_v2" && record.Description != "helmet v2" { + t.Fatalf("expected renamed template, got %#v", record) + } } func TestUI_ActionAssetTemplateDeleteFromDetailPage(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/delete", nil) - rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/delete", nil) + rr := httptest.NewRecorder() - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - router.ServeHTTP(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?msg=") { - t.Fatalf("expected list redirect with message, got %q", got) - } - record, err := repo.GetTemplate("helmet") - if err != nil { - t.Fatalf("GetTemplate: %v", err) - } - if record != nil { - t.Fatalf("expected deleted template, got %#v", record) - } + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?msg=") { + t.Fatalf("expected list redirect with message, got %q", got) + } + record, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if record != nil { + t.Fatalf("expected deleted template, got %#v", record) + } } func TestUI_AssetTemplateGraphSaveRejectsUnknownEdgeEndpoint(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - form := url.Values{} - form.Set("json", `{"name":"helmet","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","missing"]]}}`) - req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("json", `{"name":"helmet","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","missing"]]}}`) + req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - router.ServeHTTP(rr, req) - if rr.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) - } - if !strings.Contains(rr.Body.String(), "edge references unknown node") { - t.Fatalf("expected edge validation error, got: %s", rr.Body.String()) - } + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "edge references unknown node") { + t.Fatalf("expected edge validation error, got: %s", rr.Body.String()) + } } func TestUI_AssetTemplatePageMarksBuiltinTemplateReadonly(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream") - for _, want := range []string{"标准模板(只读)", "复制标准模板", "可视化预览", "builtin helmet"} { - if !strings.Contains(html, want) { - t.Fatalf("expected builtin template page to contain %q, got: %s", want, html) - } - } + html := renderPage(t, ui, "/ui/assets/templates?name=std_face_recognition_stream") + for _, want := range []string{"标准模板(只读)", "复制标准模板", "可视化预览", "builtin helmet"} { + if !strings.Contains(html, want) { + t.Fatalf("expected builtin template page to contain %q, got: %s", want, html) + } + } } func TestUI_AssetTemplateGraphSaveRejectsBuiltinTemplate(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - repo := storage.NewAssetsRepo(store.DB()) - if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { - t.Fatalf("SaveTemplate: %v", err) - } - ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("std_face_recognition_stream", "builtin helmet", `{"name":"std_face_recognition_stream","description":"builtin helmet","source":"standard","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + ui.preview = service.NewConfigPreviewService(&config.Config{}, repo) - form := url.Values{} - form.Set("json", `{"name":"std_face_recognition_stream","description":"builtin helmet","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`) - req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/graph", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() + form := url.Values{} + form.Set("json", `{"name":"std_face_recognition_stream","description":"builtin helmet","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`) + req := httptest.NewRequest(http.MethodPost, "/assets/templates/std_face_recognition_stream/graph", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() - router, err := ui.Routes() - if err != nil { - t.Fatalf("Routes: %v", err) - } - router.ServeHTTP(rr, req) - if rr.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) - } - if !strings.Contains(rr.Body.String(), "read-only") { - t.Fatalf("expected readonly rejection, got: %s", rr.Body.String()) - } + router, err := ui.Routes() + if err != nil { + t.Fatalf("Routes: %v", err) + } + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "read-only") { + t.Fatalf("expected readonly rejection, got: %s", rr.Body.String()) + } } func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - auditRepo := storage.NewAuditLogsRepo(store.DB()) - if err := auditRepo.Append(storage.AuditLogRecord{ - Actor: "system", - Action: "config_apply", - TargetType: "device", - TargetID: "edge-01", - DetailsJSON: `{"task_id":"task-99","profile":"gate_a","status":"success"}`, - }); err != nil { - t.Fatalf("Append: %v", err) - } - ui.auditRepo = auditRepo + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + auditRepo := storage.NewAuditLogsRepo(store.DB()) + if err := auditRepo.Append(storage.AuditLogRecord{ + Actor: "system", + Action: "config_apply", + TargetType: "device", + TargetID: "edge-01", + DetailsJSON: `{"task_id":"task-99","profile":"gate_a","status":"success"}`, + }); err != nil { + t.Fatalf("Append: %v", err) + } + ui.auditRepo = auditRepo - html := renderPage(t, ui, "/ui/audit") - for _, want := range []string{"task-99", "gate_a", "下发设备分配", "成功", "edge-01"} { - if !strings.Contains(html, want) { - t.Fatalf("expected audit page to contain %q, got: %s", want, html) - } - } - if strings.Contains(html, "暂无审计记录") { - t.Fatalf("expected persisted audit logs to replace empty state, got: %s", html) - } - if strings.Contains(html, "config_apply") || strings.Contains(html, "success") { - t.Fatalf("expected audit page to avoid raw enums, got: %s", html) - } + html := renderPage(t, ui, "/ui/audit") + for _, want := range []string{"task-99", "gate_a", "下发设备分配", "成功", "edge-01"} { + if !strings.Contains(html, want) { + t.Fatalf("expected audit page to contain %q, got: %s", want, html) + } + } + if strings.Contains(html, "暂无审计记录") { + t.Fatalf("expected persisted audit logs to replace empty state, got: %s", html) + } + if strings.Contains(html, "config_apply") || strings.Contains(html, "success") { + t.Fatalf("expected audit page to avoid raw enums, got: %s", html) + } } func TestUI_DeviceDetailFallsBackToPersistedConfigState(t *testing.T) { - ui := newTestUI(t) - store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) - if err != nil { - t.Fatalf("OpenSQLite: %v", err) - } - defer store.Close() - stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) - if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{ - DeviceID: "edge-01", - TemplateName: "helmet", - ProfileName: "gate_a", - OverlaysJSON: `["night_relaxed"]`, - ConfigID: "cfg-001", - ConfigVersion: "20260427.1", - LastAppliedTaskID: "task-1", - }); err != nil { - t.Fatalf("Upsert: %v", err) - } - ui.stateRepo = stateRepo + ui := newTestUI(t) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + stateRepo := storage.NewDeviceConfigStateRepo(store.DB()) + if err := stateRepo.Upsert(storage.DeviceConfigStateRecord{ + DeviceID: "edge-01", + TemplateName: "helmet", + ProfileName: "gate_a", + OverlaysJSON: `["night_relaxed"]`, + ConfigID: "cfg-001", + ConfigVersion: "20260427.1", + LastAppliedTaskID: "task-1", + }); err != nil { + t.Fatalf("Upsert: %v", err) + } + ui.stateRepo = stateRepo - html := renderPage(t, ui, "/ui/devices/edge-01") - for _, want := range []string{"cfg-001", "20260427.1", "helmet", "gate_a", "night_relaxed"} { - if !strings.Contains(html, want) { - t.Fatalf("expected device detail to contain %q, got: %s", want, html) - } - } + html := renderPage(t, ui, "/ui/devices/edge-01") + for _, want := range []string{"cfg-001", "20260427.1", "helmet", "gate_a", "night_relaxed"} { + if !strings.Contains(html, want) { + t.Fatalf("expected device detail to contain %q, got: %s", want, html) + } + } } func TestUI_SystemPageShowsDatabaseBackupAction(t *testing.T) { - ui := newTestUI(t) - ui.dbPath = filepath.Join(t.TempDir(), "app.db") + ui := newTestUI(t) + ui.dbPath = filepath.Join(t.TempDir(), "app.db") - html := renderPage(t, ui, "/ui/system") - for _, want := range []string{ - "数据备份", - "恢复数据库", - `class="btn ghost js-export-db"`, - `data-export-url="/ui/system/db-backup"`, - `data-default-filename="app.db"`, - "showSaveFilePicker", - "当前浏览器不支持选择保存目录和文件名", - } { - if !strings.Contains(html, want) { - t.Fatalf("expected system page to contain %q, got: %s", want, html) - } - } + html := renderPage(t, ui, "/ui/system") + for _, want := range []string{ + "数据备份", + "恢复数据库", + `class="btn ghost js-export-db"`, + `data-export-url="/ui/system/db-backup"`, + `data-default-filename="app.db"`, + "showSaveFilePicker", + "当前浏览器不支持选择保存目录和文件名", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected system page to contain %q, got: %s", want, html) + } + } } func TestUI_SystemPageShowsFlashMessageFromQuery(t *testing.T) { - ui := newTestUI(t) - ui.dbPath = filepath.Join(t.TempDir(), "app.db") + ui := newTestUI(t) + ui.dbPath = filepath.Join(t.TempDir(), "app.db") - html := renderPage(t, ui, "/ui/system?msg=%E6%95%B0%E6%8D%AE%E5%BA%93%E6%81%A2%E5%A4%8D%E5%AE%8C%E6%88%90") - if !strings.Contains(html, "数据库恢复完成") { - t.Fatalf("expected system page to show success message, got: %s", html) - } + html := renderPage(t, ui, "/ui/system?msg=%E6%95%B0%E6%8D%AE%E5%BA%93%E6%81%A2%E5%A4%8D%E5%AE%8C%E6%88%90") + if !strings.Contains(html, "数据库恢复完成") { + t.Fatalf("expected system page to show success message, got: %s", html) + } } func TestUI_SystemDBBackupUsesTimestampedFilename(t *testing.T) { - ui := newTestUI(t) - dir := t.TempDir() - ui.dbPath = filepath.Join(dir, "app.db") - if err := os.WriteFile(ui.dbPath, []byte("sqlite-bytes"), 0644); err != nil { - t.Fatalf("WriteFile: %v", err) - } + ui := newTestUI(t) + dir := t.TempDir() + ui.dbPath = filepath.Join(dir, "app.db") + if err := os.WriteFile(ui.dbPath, []byte("sqlite-bytes"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } - req := httptest.NewRequest(http.MethodGet, "/ui/system/db-backup", nil) - rr := httptest.NewRecorder() - ui.pageSystemDBBackup(rr, req) + req := httptest.NewRequest(http.MethodGet, "/ui/system/db-backup", nil) + rr := httptest.NewRecorder() + ui.pageSystemDBBackup(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - got := rr.Header().Get("Content-Disposition") - if !strings.Contains(got, "attachment;") || !strings.Contains(got, "app-") || !strings.Contains(got, ".db") { - t.Fatalf("expected timestamped filename, got %q", got) - } + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + got := rr.Header().Get("Content-Disposition") + if !strings.Contains(got, "attachment;") || !strings.Contains(got, "app-") || !strings.Contains(got, ".db") { + t.Fatalf("expected timestamped filename, got %q", got) + } } func TestUI_SystemDBRestoreReplacesDatabaseFile(t *testing.T) { - ui := newTestUI(t) - dir := t.TempDir() - ui.dbPath = filepath.Join(dir, "app.db") - if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil { - t.Fatalf("WriteFile: %v", err) - } + ui := newTestUI(t) + dir := t.TempDir() + ui.dbPath = filepath.Join(dir, "app.db") + if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } - var body bytes.Buffer - writer := multipart.NewWriter(&body) - part, err := writer.CreateFormFile("file", "restore.db") - if err != nil { - t.Fatalf("CreateFormFile: %v", err) - } - if _, err := part.Write([]byte("new-db")); err != nil { - t.Fatalf("Write part: %v", err) - } - if err := writer.Close(); err != nil { - t.Fatalf("Close writer: %v", err) - } + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "restore.db") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + if _, err := part.Write([]byte("new-db")); err != nil { + t.Fatalf("Write part: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close writer: %v", err) + } - req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - rr := httptest.NewRecorder() - ui.actionSystemDBRestore(rr, req) + req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + ui.actionSystemDBRestore(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) - } - restored, err := os.ReadFile(ui.dbPath) - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - if string(restored) != "new-db" { - t.Fatalf("expected restored db contents, got %q", string(restored)) - } + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) + } + restored, err := os.ReadFile(ui.dbPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(restored) != "new-db" { + t.Fatalf("expected restored db contents, got %q", string(restored)) + } } func TestUI_SystemDBRestoreRequiresSelectedFile(t *testing.T) { - ui := newTestUI(t) - dir := t.TempDir() - ui.dbPath = filepath.Join(dir, "app.db") - if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil { - t.Fatalf("WriteFile: %v", err) - } + ui := newTestUI(t) + dir := t.TempDir() + ui.dbPath = filepath.Join(dir, "app.db") + if err := os.WriteFile(ui.dbPath, []byte("old-db"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } - var body bytes.Buffer - writer := multipart.NewWriter(&body) - if err := writer.Close(); err != nil { - t.Fatalf("Close writer: %v", err) - } + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.Close(); err != nil { + t.Fatalf("Close writer: %v", err) + } - req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - rr := httptest.NewRecorder() - ui.actionSystemDBRestore(rr, req) + req := httptest.NewRequest(http.MethodPost, "/ui/system/db-restore", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + ui.actionSystemDBRestore(rr, req) - if rr.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) - } - html := rr.Body.String() - if !strings.Contains(html, "请先选择数据库备份文件") { - t.Fatalf("expected friendly error message, got: %s", html) - } - if strings.Contains(html, "http: no such file") { - t.Fatalf("expected raw form-file error to stay hidden, got: %s", html) - } + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + html := rr.Body.String() + if !strings.Contains(html, "请先选择数据库备份文件") { + t.Fatalf("expected friendly error message, got: %s", html) + } + if strings.Contains(html, "http: no such file") { + t.Fatalf("expected raw form-file error to stay hidden, got: %s", html) + } }