package api import ( "fmt" "io" "net/http" "strings" "time" "visiona-local/server/internal/api/handlers" "visiona-local/server/internal/api/ws" "visiona-local/server/internal/camera" "visiona-local/server/internal/device" "visiona-local/server/internal/inference" "visiona-local/server/internal/model" "visiona-local/server/pkg/logger" "github.com/gin-gonic/gin" ) func NewRouter( modelRepo *model.Repository, modelStore *model.ModelStore, deviceMgr *device.Manager, cameraMgr *camera.Manager, inferenceSvc *inference.Service, wsHub *ws.Hub, staticFS http.FileSystem, logBroadcaster *logger.Broadcaster, systemHandler *handlers.SystemHandler, ) *gin.Engine { // Use gin.New() instead of gin.Default() to replace the default logger // with one that also pushes to the WebSocket broadcaster. r := gin.New() r.Use(gin.Recovery()) r.Use(broadcasterLogger(logBroadcaster)) r.Use(CORSMiddleware()) modelHandler := handlers.NewModelHandler(modelRepo) modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore) deviceHandler := handlers.NewDeviceHandler(deviceMgr, inferenceSvc, wsHub) cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub) api := r.Group("/api") { // System api.GET("/system/health", systemHandler.HealthCheck) api.GET("/system/info", systemHandler.Info) api.GET("/system/metrics", systemHandler.Metrics) api.GET("/system/deps", systemHandler.Deps) api.POST("/system/restart", systemHandler.Restart) api.POST("/system/install-driver", systemHandler.InstallDriver) // Models api.GET("/models", modelHandler.ListModels) api.GET("/models/:id", modelHandler.GetModel) api.POST("/models/upload", modelUploadHandler.UploadModel) api.DELETE("/models/:id", modelUploadHandler.DeleteModel) // Devices api.GET("/devices", deviceHandler.ListDevices) api.POST("/devices/scan", deviceHandler.ScanDevices) api.GET("/devices/:id", deviceHandler.GetDevice) api.POST("/devices/:id/connect", deviceHandler.ConnectDevice) api.POST("/devices/:id/disconnect", deviceHandler.DisconnectDevice) api.POST("/devices/:id/inference/start", deviceHandler.StartInference) api.POST("/devices/:id/inference/stop", deviceHandler.StopInference) // Camera api.GET("/camera/list", cameraHandler.ListCameras) api.POST("/camera/start", cameraHandler.StartPipeline) api.POST("/camera/stop", cameraHandler.StopPipeline) api.GET("/camera/stream", cameraHandler.StreamMJPEG) // Media api.POST("/media/upload/image", cameraHandler.UploadImage) api.POST("/media/upload/video", cameraHandler.UploadVideo) api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages) api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame) api.POST("/media/url", cameraHandler.StartFromURL) api.POST("/media/seek", cameraHandler.SeekVideo) } // WebSocket r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr)) r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc)) r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster)) // Embedded frontend static file serving (production mode) if staticFS != nil { fileServer := http.FileServer(staticFS) // Serve Next.js-style static assets r.GET("/_next/*filepath", func(c *gin.Context) { fileServer.ServeHTTP(c.Writer, c.Request) }) r.GET("/favicon.ico", func(c *gin.Context) { fileServer.ServeHTTP(c.Writer, c.Request) }) // SPA fallback for all other routes (client-side routing) r.NoRoute(spaFallback(staticFS)) } return r } // broadcasterLogger is a Gin middleware that logs HTTP requests to both // stdout (like gin.Logger) and the WebSocket log broadcaster so that // request logs are visible in the frontend Settings page. func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path raw := c.Request.URL.RawQuery c.Next() latency := time.Since(start) status := c.Writer.Status() method := c.Request.Method if raw != "" { path = path + "?" + raw } msg := fmt.Sprintf("%3d | %13v | %-7s %s", status, latency, method, path) // Write to stdout (original Gin behaviour) fmt.Printf("[GIN] %s\n", msg) // Push to broadcaster for WebSocket streaming if b != nil { level := "INFO" if status >= 500 { level = "ERROR" } else if status >= 400 { level = "WARN" } b.Push(level, fmt.Sprintf("[GIN] %s", msg)) } } } // spaFallback tries to serve the exact file from the embedded FS. // If the file doesn't exist, it serves index.html for client-side routing. func spaFallback(staticFS http.FileSystem) gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path // Don't serve index.html for API or WebSocket routes if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") { c.Status(http.StatusNotFound) return } // Try to serve the exact file (e.g., /models/index.html) f, err := staticFS.Open(path) if err == nil { f.Close() http.FileServer(staticFS).ServeHTTP(c.Writer, c.Request) return } // Fall back to root index.html for SPA routing index, err := staticFS.Open("/index.html") if err != nil { c.Status(http.StatusInternalServerError) return } defer index.Close() c.Header("Content-Type", "text/html; charset=utf-8") c.Status(http.StatusOK) _, _ = io.Copy(c.Writer, index) } }