chore: convert all tabs to spaces

This commit is contained in:
tian 2026-05-08 20:05:40 +08:00
parent 1bcd9ae3a5
commit b7268212e3
53 changed files with 16787 additions and 16787 deletions

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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())
}
}

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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)
}
}

View File

@ -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 ""
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
}

File diff suppressed because it is too large Load Diff

View File

@ -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[:])
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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])
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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])
}
}

View File

@ -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
}

View File

@ -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, &notnull, &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, &notnull, &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 ""
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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"),
}
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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"])
}
}

View File

@ -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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff