chore: convert all tabs to spaces
This commit is contained in:
parent
1bcd9ae3a5
commit
b7268212e3
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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[:])
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const schema001 = `
|
||||
@ -178,39 +178,39 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
`
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
if _, err := db.Exec(schema001); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateProfilePrimaryTemplateName(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateProfilesToSceneTemplates(db)
|
||||
if _, err := db.Exec(schema001); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateProfilePrimaryTemplateName(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateProfilesToSceneTemplates(db)
|
||||
}
|
||||
|
||||
func migrateProfilePrimaryTemplateName(db *sql.DB) error {
|
||||
hasPrimary, err := hasColumn(db, "profiles", "primary_template_name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasPrimary {
|
||||
return nil
|
||||
}
|
||||
hasLegacy, err := hasColumn(db, "profiles", "template_name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasLegacy {
|
||||
return fmt.Errorf("profiles table is missing both primary_template_name and template_name")
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
hasPrimary, err := hasColumn(db, "profiles", "primary_template_name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasPrimary {
|
||||
return nil
|
||||
}
|
||||
hasLegacy, err := hasColumn(db, "profiles", "template_name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasLegacy {
|
||||
return fmt.Errorf("profiles table is missing both primary_template_name and template_name")
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE profiles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@ -221,243 +221,243 @@ CREATE TABLE profiles (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO profiles(id, name, primary_template_name, business_name, description, body_json, created_at, updated_at)
|
||||
SELECT id, name, template_name, business_name, description, body_json, created_at, updated_at
|
||||
FROM profiles_legacy_template_name
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func hasColumn(db *sql.DB, table string, column string) (bool, error) {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dfltValue sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dfltValue sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
func migrateProfilesToSceneTemplates(db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(1) FROM scene_templates`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(1) FROM scene_templates`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
SELECT name, primary_template_name, business_name, description, body_json, created_at, updated_at
|
||||
FROM profiles
|
||||
ORDER BY created_at ASC, name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type legacyProfile struct {
|
||||
Name string
|
||||
TemplateName string
|
||||
BusinessName string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
legacy := make([]legacyProfile, 0)
|
||||
for rows.Next() {
|
||||
var item legacyProfile
|
||||
if err := rows.Scan(&item.Name, &item.TemplateName, &item.BusinessName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(legacy) == 0 {
|
||||
return nil
|
||||
}
|
||||
type legacyProfile struct {
|
||||
Name string
|
||||
TemplateName string
|
||||
BusinessName string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
legacy := make([]legacyProfile, 0)
|
||||
for rows.Next() {
|
||||
var item legacyProfile
|
||||
if err := rows.Scan(&item.Name, &item.TemplateName, &item.BusinessName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(legacy) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for _, item := range legacy {
|
||||
templateDoc, units, err := splitLegacyProfileDocument(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templateBody, err := json.MarshalIndent(templateDoc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdAt := firstNonEmpty(item.CreatedAt, now)
|
||||
updatedAt := firstNonEmpty(item.UpdatedAt, createdAt)
|
||||
if _, err := tx.Exec(`
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for _, item := range legacy {
|
||||
templateDoc, units, err := splitLegacyProfileDocument(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templateBody, err := json.MarshalIndent(templateDoc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdAt := firstNonEmpty(item.CreatedAt, now)
|
||||
updatedAt := firstNonEmpty(item.UpdatedAt, createdAt)
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO NOTHING
|
||||
`, item.Name, item.TemplateName, item.BusinessName, item.Description, string(append(templateBody, '\n')), createdAt, updatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, unit := range units {
|
||||
if _, err := tx.Exec(`
|
||||
return err
|
||||
}
|
||||
for _, unit := range units {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scene_template_name, name) DO NOTHING
|
||||
`, item.Name, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, item.Description, unit.BodyJSON, createdAt, updatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type migratedRecognitionUnit struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
SiteName string
|
||||
VideoSourceRef string
|
||||
OutputChannel string
|
||||
RTSPPort string
|
||||
BodyJSON string
|
||||
Name string
|
||||
DisplayName string
|
||||
SiteName string
|
||||
VideoSourceRef string
|
||||
OutputChannel string
|
||||
RTSPPort string
|
||||
BodyJSON string
|
||||
}
|
||||
|
||||
func splitLegacyProfileDocument(item struct {
|
||||
Name string
|
||||
TemplateName string
|
||||
BusinessName string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Name string
|
||||
TemplateName string
|
||||
BusinessName string
|
||||
Description string
|
||||
BodyJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}) (map[string]any, []migratedRecognitionUnit, error) {
|
||||
raw := map[string]any{}
|
||||
if strings.TrimSpace(item.BodyJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(item.BodyJSON), &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
raw["name"] = item.Name
|
||||
if strings.TrimSpace(item.TemplateName) != "" {
|
||||
raw["primary_template_name"] = item.TemplateName
|
||||
}
|
||||
if strings.TrimSpace(item.BusinessName) != "" {
|
||||
raw["business_name"] = item.BusinessName
|
||||
}
|
||||
if strings.TrimSpace(item.Description) != "" {
|
||||
raw["description"] = item.Description
|
||||
}
|
||||
var units []migratedRecognitionUnit
|
||||
if instances, ok := raw["instances"].([]any); ok {
|
||||
for _, entry := range instances {
|
||||
inst, _ := entry.(map[string]any)
|
||||
if inst == nil {
|
||||
continue
|
||||
}
|
||||
unitName := strings.TrimSpace(stringAny(inst["name"]))
|
||||
if unitName == "" {
|
||||
continue
|
||||
}
|
||||
sceneMeta, _ := inst["scene_meta"].(map[string]any)
|
||||
inputBindings, _ := inst["input_bindings"].(map[string]any)
|
||||
outputBindings, _ := inst["output_bindings"].(map[string]any)
|
||||
videoSourceRef := strings.TrimSpace(bindingStringFromAny(inputBindings, "video_input_main", "video_source_ref"))
|
||||
outputChannel := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "channel_no"))
|
||||
if outputChannel == "" {
|
||||
outputChannel = unitName
|
||||
}
|
||||
rtspPort := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "publish_rtsp_port"))
|
||||
if rtspPort == "" {
|
||||
rtspPort = "8555"
|
||||
}
|
||||
unitRaw := cloneAnyMap(inst)
|
||||
body, err := json.MarshalIndent(unitRaw, "", " ")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
units = append(units, migratedRecognitionUnit{
|
||||
Name: unitName,
|
||||
DisplayName: strings.TrimSpace(stringAny(sceneMeta["display_name"])),
|
||||
SiteName: strings.TrimSpace(stringAny(sceneMeta["site_name"])),
|
||||
VideoSourceRef: videoSourceRef,
|
||||
OutputChannel: outputChannel,
|
||||
RTSPPort: rtspPort,
|
||||
BodyJSON: string(append(body, '\n')),
|
||||
})
|
||||
}
|
||||
}
|
||||
delete(raw, "instances")
|
||||
return raw, units, nil
|
||||
raw := map[string]any{}
|
||||
if strings.TrimSpace(item.BodyJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(item.BodyJSON), &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
raw["name"] = item.Name
|
||||
if strings.TrimSpace(item.TemplateName) != "" {
|
||||
raw["primary_template_name"] = item.TemplateName
|
||||
}
|
||||
if strings.TrimSpace(item.BusinessName) != "" {
|
||||
raw["business_name"] = item.BusinessName
|
||||
}
|
||||
if strings.TrimSpace(item.Description) != "" {
|
||||
raw["description"] = item.Description
|
||||
}
|
||||
var units []migratedRecognitionUnit
|
||||
if instances, ok := raw["instances"].([]any); ok {
|
||||
for _, entry := range instances {
|
||||
inst, _ := entry.(map[string]any)
|
||||
if inst == nil {
|
||||
continue
|
||||
}
|
||||
unitName := strings.TrimSpace(stringAny(inst["name"]))
|
||||
if unitName == "" {
|
||||
continue
|
||||
}
|
||||
sceneMeta, _ := inst["scene_meta"].(map[string]any)
|
||||
inputBindings, _ := inst["input_bindings"].(map[string]any)
|
||||
outputBindings, _ := inst["output_bindings"].(map[string]any)
|
||||
videoSourceRef := strings.TrimSpace(bindingStringFromAny(inputBindings, "video_input_main", "video_source_ref"))
|
||||
outputChannel := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "channel_no"))
|
||||
if outputChannel == "" {
|
||||
outputChannel = unitName
|
||||
}
|
||||
rtspPort := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "publish_rtsp_port"))
|
||||
if rtspPort == "" {
|
||||
rtspPort = "8555"
|
||||
}
|
||||
unitRaw := cloneAnyMap(inst)
|
||||
body, err := json.MarshalIndent(unitRaw, "", " ")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
units = append(units, migratedRecognitionUnit{
|
||||
Name: unitName,
|
||||
DisplayName: strings.TrimSpace(stringAny(sceneMeta["display_name"])),
|
||||
SiteName: strings.TrimSpace(stringAny(sceneMeta["site_name"])),
|
||||
VideoSourceRef: videoSourceRef,
|
||||
OutputChannel: outputChannel,
|
||||
RTSPPort: rtspPort,
|
||||
BodyJSON: string(append(body, '\n')),
|
||||
})
|
||||
}
|
||||
}
|
||||
delete(raw, "instances")
|
||||
return raw, units, nil
|
||||
}
|
||||
|
||||
func bindingStringFromAny(bindings map[string]any, slot string, field string) string {
|
||||
entry, _ := bindings[slot].(map[string]any)
|
||||
return strings.TrimSpace(stringAny(entry[field]))
|
||||
entry, _ := bindings[slot].(map[string]any)
|
||||
return strings.TrimSpace(stringAny(entry[field]))
|
||||
}
|
||||
|
||||
func stringAny(v any) string {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", vv)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", vv)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAnyMap(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
if len(in) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
7258
internal/web/ui.go
7258
internal/web/ui.go
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user