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 <noreply@anthropic.com>
This commit is contained in:
308
backend/internal/api/handlers/logs.go
Normal file
308
backend/internal/api/handlers/logs.go
Normal file
@@ -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})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user