From 392f93be95ff502d0c427ba3b5a71b9635896c86 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Feb 2026 00:57:53 +0300 Subject: [PATCH] feat: Implement Log Query API Endpoints (Issue #13) - Create LogsHandler with all log query endpoints - Implement QueryLogs for LogsQL queries - Implement StatsQuery for time-series statistics - Implement GetFacets for field facets - Implement TailLogs for real-time streaming (NDJSON) - Implement ExportLogs with JSON and CSV support - Wire up logs handler in router - Add VictoriaLogs client initialization Closes #13 Co-Authored-By: Claude Sonnet 4.5 --- backend/internal/api/handlers/logs.go | 308 ++++++++++++++++++++++++++ backend/internal/api/router.go | 26 +-- 2 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 backend/internal/api/handlers/logs.go diff --git a/backend/internal/api/handlers/logs.go b/backend/internal/api/handlers/logs.go new file mode 100644 index 0000000..041af4d --- /dev/null +++ b/backend/internal/api/handlers/logs.go @@ -0,0 +1,308 @@ +package handlers + +import ( + "encoding/csv" + "encoding/json" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/yourusername/victorialogs-manager/internal/models" + "github.com/yourusername/victorialogs-manager/internal/services" +) + +// LogsHandler handles log querying endpoints +type LogsHandler struct { + logsService *services.LogsService +} + +// NewLogsHandler creates a new logs handler +func NewLogsHandler(logsService *services.LogsService) *LogsHandler { + return &LogsHandler{ + logsService: logsService, + } +} + +// QueryLogs handles log queries +// @Summary Query logs +// @Description Execute a LogsQL query +// @Tags logs +// @Accept json +// @Produce json +// @Param request body services.QueryLogsRequest true "Query parameters" +// @Success 200 {object} services.QueryLogsResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/logs/query [post] +func (h *LogsHandler) QueryLogs(c *gin.Context) { + var req services.QueryLogsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + // Get user role from context + roleInterface, _ := c.Get("role") + userRole := roleInterface.(models.Role) + + resp, err := h.logsService.QueryLogs(c.Request.Context(), &req, userRole) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "query failed", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, resp) +} + +// StatsQuery handles stats queries +// @Summary Get log statistics +// @Description Execute a stats query for time-series data +// @Tags logs +// @Accept json +// @Produce json +// @Param request body services.StatsQueryRequest true "Stats query parameters" +// @Success 200 {object} services.StatsQueryResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/logs/stats [post] +func (h *LogsHandler) StatsQuery(c *gin.Context) { + var req services.StatsQueryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + resp, err := h.logsService.StatsQuery(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "stats query failed", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, resp) +} + +// GetFacets handles facets requests +// @Summary Get field facets +// @Description Get facet values for log fields +// @Tags logs +// @Accept json +// @Produce json +// @Param request body services.GetFacetsRequest true "Facets parameters" +// @Success 200 {object} services.GetFacetsResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/logs/facets [post] +func (h *LogsHandler) GetFacets(c *gin.Context) { + var req services.GetFacetsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + resp, err := h.logsService.GetFacets(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "facets query failed", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, resp) +} + +// TailLogs handles WebSocket log tailing +// @Summary Tail logs in real-time +// @Description Stream logs in real-time via WebSocket +// @Tags logs +// @Accept json +// @Produce json +// @Param request body services.TailLogsRequest true "Tail parameters" +// @Success 200 {object} SuccessResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/logs/tail [post] +func (h *LogsHandler) TailLogs(c *gin.Context) { + var req services.TailLogsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + // Start tailing + logChan, errChan, err := h.logsService.TailLogs(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "tail failed", + Message: err.Error(), + }) + return + } + + // Set headers for streaming + c.Header("Content-Type", "application/x-ndjson") + c.Header("Transfer-Encoding", "chunked") + c.Header("X-Content-Type-Options", "nosniff") + + // Stream logs as NDJSON + c.Stream(func(w gin.ResponseWriter) bool { + select { + case log, ok := <-logChan: + if !ok { + return false + } + data, _ := json.Marshal(log) + w.Write(data) + w.Write([]byte("\n")) + w.Flush() + return true + case err := <-errChan: + if err != nil { + errData, _ := json.Marshal(ErrorResponse{ + Error: "stream error", + Message: err.Error(), + }) + w.Write(errData) + w.Write([]byte("\n")) + w.Flush() + } + return false + case <-c.Request.Context().Done(): + return false + } + }) +} + +// ExportLogs handles log export +// @Summary Export logs +// @Description Export logs in JSON or CSV format +// @Tags logs +// @Accept json +// @Produce json,text/csv +// @Param request body services.ExportLogsRequest true "Export parameters" +// @Success 200 {file} file +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/v1/logs/export [post] +func (h *LogsHandler) ExportLogs(c *gin.Context) { + var req services.ExportLogsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid request", + Message: err.Error(), + }) + return + } + + // Default format to JSON + if req.Format == "" { + req.Format = "json" + } + + // Get user role from context + roleInterface, _ := c.Get("role") + userRole := roleInterface.(models.Role) + + logs, err := h.logsService.ExportLogs(c.Request.Context(), &req, userRole) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "export failed", + Message: err.Error(), + }) + return + } + + // Export based on format + switch req.Format { + case "csv": + h.exportCSV(c, logs) + case "json": + h.exportJSON(c, logs) + default: + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "invalid format", + Message: "format must be 'json' or 'csv'", + }) + } +} + +// exportJSON exports logs as JSON +func (h *LogsHandler) exportJSON(c *gin.Context, logs []interface{}) { + filename := "logs_" + strconv.FormatInt(c.Request.Context().Value("timestamp").(int64), 10) + ".json" + + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Header("Content-Type", "application/json") + + c.JSON(http.StatusOK, gin.H{ + "logs": logs, + "count": len(logs), + }) +} + +// exportCSV exports logs as CSV +func (h *LogsHandler) exportCSV(c *gin.Context, logs []interface{}) { + filename := "logs_" + strconv.FormatInt(c.Request.Context().Value("timestamp").(int64), 10) + ".csv" + + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Header("Content-Type", "text/csv") + + writer := csv.NewWriter(c.Writer) + defer writer.Flush() + + // Write CSV header + writer.Write([]string{"timestamp", "message", "stream_id", "fields"}) + + // Write logs + for _, log := range logs { + logMap, ok := log.(map[string]interface{}) + if !ok { + continue + } + + timestamp := "" + if t, ok := logMap["_time"]; ok { + timestamp = t.(string) + } + + message := "" + if m, ok := logMap["_msg"]; ok { + message = m.(string) + } + + streamID := "" + if s, ok := logMap["_stream_id"]; ok { + streamID = s.(string) + } + + fields := "" + if f, ok := logMap["fields"]; ok { + fieldsJSON, _ := json.Marshal(f) + fields = string(fieldsJSON) + } + + writer.Write([]string{timestamp, message, streamID, fields}) + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 5436770..f218b95 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -11,6 +11,7 @@ import ( "github.com/yourusername/victorialogs-manager/internal/repository" "github.com/yourusername/victorialogs-manager/internal/services" "github.com/yourusername/victorialogs-manager/internal/utils" + "github.com/yourusername/victorialogs-manager/pkg/vlogs" ) // NewRouter creates and configures the main router @@ -35,13 +36,18 @@ func NewRouter(cfg *config.Config, db *sql.DB) *gin.Engine { // Initialize repositories userRepo := repository.NewUserRepository(db) + // Initialize VictoriaLogs client + vlogsClient := vlogs.NewClient(cfg.VictoriaLogs.URL, cfg.VictoriaLogs.Timeout) + // Initialize services authService := services.NewAuthService(userRepo, jwtManager) userService := services.NewUserService(userRepo) + logsService := services.NewLogsService(vlogsClient) // Initialize handlers authHandler := handlers.NewAuthHandler(authService) userHandler := handlers.NewUserHandler(userService) + logsHandler := handlers.NewLogsHandler(logsService) // Health check endpoint router.GET("/health", func(c *gin.Context) { @@ -86,21 +92,11 @@ func NewRouter(cfg *config.Config, db *sql.DB) *gin.Engine { logs := v1.Group("/logs") logs.Use(middleware.AuthMiddleware(jwtManager)) { - logs.POST("/query", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - logs.POST("/tail", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - logs.GET("/facets", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - logs.POST("/stats", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) - logs.POST("/export", func(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "not implemented yet"}) - }) + logs.POST("/query", logsHandler.QueryLogs) + logs.POST("/tail", logsHandler.TailLogs) + logs.POST("/facets", logsHandler.GetFacets) + logs.POST("/stats", logsHandler.StatsQuery) + logs.POST("/export", logsHandler.ExportLogs) } // Alert management routes (protected)