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:
Claude Code
2026-02-05 00:57:53 +03:00
parent c9b6c5bf74
commit 392f93be95
2 changed files with 319 additions and 15 deletions

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