Ctrl K
ComunidadGuías…

server/internal/handlers/entries.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handlers

import (
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"strconv"
	"strings"
	"time"
	"unicode"

	"blackbox/shared/types"
	"github.com/go-chi/chi/v5"
	"gorm.io/gorm"
)

type entryCursor struct {
	Timestamp time.Time
	ID        string
}

func ListEntries(database *gorm.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		cursor := r.URL.Query().Get("cursor")
		limitStr := r.URL.Query().Get("limit")
		node := r.URL.Query().Get("node")
		source := r.URL.Query().Get("source")
		service := r.URL.Query().Get("service")
		q := r.URL.Query().Get("q")
		timeStartStr := r.URL.Query().Get("time_start")
		timeEndStr := r.URL.Query().Get("time_end")

		limit := 50
		if limitStr != "" {
			if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 200 {
				limit = n
			}
		}

		usingFTS := false
		tx := database.Model(&types.Entry{})
		if q != "" {
			if ftsQuery := buildEntryFTSQuery(q); ftsQuery != "" {
				usingFTS = true
				tx = database.Table("entries").Select("entries.*").
					Joins("JOIN entries_fts ON entries.rowid = entries_fts.rowid").
					Where("entries_fts MATCH ?", ftsQuery)
			}
		}
		timestampColumn := "timestamp"
		idColumn := "id"
		if usingFTS {
			timestampColumn = "entries.timestamp"
			idColumn = "entries.id"
		}
		tx = tx.Order(timestampColumn + " DESC").Order(idColumn + " DESC").Limit(limit + 1)
		if cursor != "" {
			parsedCursor, err := parseEntryCursor(cursor)
			if err != nil {
				writeError(w, http.StatusBadRequest, "invalid cursor")
				return
			}
			tx = tx.Where(
				timestampColumn+" < ? OR ("+timestampColumn+" = ? AND "+idColumn+" < ?)",
				parsedCursor.Timestamp,
				parsedCursor.Timestamp,
				parsedCursor.ID,
			)
		}
		if timeStartStr != "" {
			parsedTimeStart, err := time.Parse(time.RFC3339Nano, timeStartStr)
			if err != nil {
				writeError(w, http.StatusBadRequest, "invalid time_start")
				return
			}
			tx = tx.Where(timestampColumn+" >= ?", parsedTimeStart)
		}
		if timeEndStr != "" {
			parsedTimeEnd, err := time.Parse(time.RFC3339Nano, timeEndStr)
			if err != nil {
				writeError(w, http.StatusBadRequest, "invalid time_end")
				return
			}
			tx = tx.Where(timestampColumn+" <= ?", parsedTimeEnd)
		}
		if node != "" {
			tx = tx.Where(columnName("node_name", usingFTS)+" = ?", node)
		}
		if source != "" {
			tx = tx.Where(columnName("source", usingFTS)+" = ?", source)
		}
		if service != "" {
			tx = tx.Where(columnName("service", usingFTS)+" = ?", service)
		}
		if q != "" && !usingFTS {
			like := "%" + escapeLike(q) + "%"
			tx = tx.Where(columnName("content", usingFTS)+" LIKE ? ESCAPE '\\' OR "+columnName("service", usingFTS)+" LIKE ? ESCAPE '\\'", like, like)
		}
		hideHeartbeat := r.URL.Query().Get("hide_heartbeat") == "true"
		if hideHeartbeat {
			tx = tx.Where("NOT (" + columnName("source", usingFTS) + " = 'agent' AND " + columnName("event", usingFTS) + " = 'heartbeat')")
		}

		var entries []types.Entry
		if err := tx.Find(&entries).Error; err != nil {
			if usingFTS {
				log.Printf("entries: FTS query failed, falling back to LIKE: %v", err)
				entries, err = listEntriesFallback(database, cursor, limit, node, source, service, q, timeStartStr, timeEndStr, hideHeartbeat)
				if err != nil {
					writeError(w, http.StatusInternalServerError, "failed to fetch entries")
					return
				}
			} else {
				writeError(w, http.StatusInternalServerError, "failed to fetch entries")
				return
			}
		}

		type response struct {
			Entries    []types.Entry `json:"entries"`
			NextCursor string        `json:"next_cursor,omitempty"`
		}

		resp := response{Entries: entries}
		if len(entries) > limit {
			resp.Entries = entries[:limit]
			resp.NextCursor = encodeEntryCursor(resp.Entries[len(resp.Entries)-1])
		}
		if resp.Entries == nil {
			resp.Entries = []types.Entry{}
		}

		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(resp); err != nil {
			log.Printf("ListEntries encode: %v", err)
		}
	}
}

func columnName(name string, qualified bool) string {
	if qualified {
		return "entries." + name
	}
	return name
}

func escapeLike(s string) string {
	s = strings.ReplaceAll(s, "\\", "\\\\")
	s = strings.ReplaceAll(s, "%", "\\%")
	s = strings.ReplaceAll(s, "_", "\\_")
	return s
}

func buildEntryFTSQuery(q string) string {
	terms := strings.FieldsFunc(q, func(r rune) bool {
		return !unicode.IsLetter(r) && !unicode.IsDigit(r)
	})
	cleaned := make([]string, 0, len(terms))
	for _, term := range terms {
		term = strings.TrimSpace(term)
		if term == "" {
			continue
		}
		cleaned = append(cleaned, term+"*")
	}
	return strings.Join(cleaned, " AND ")
}

func listEntriesFallback(database *gorm.DB, cursor string, limit int, node, source, service, q, timeStartStr, timeEndStr string, hideHeartbeat bool) ([]types.Entry, error) {
	tx := database.Model(&types.Entry{}).Order("timestamp DESC").Order("id DESC").Limit(limit + 1)
	if cursor != "" {
		parsedCursor, err := parseEntryCursor(cursor)
		if err != nil {
			return nil, err
		}
		tx = tx.Where("timestamp < ? OR (timestamp = ? AND id < ?)", parsedCursor.Timestamp, parsedCursor.Timestamp, parsedCursor.ID)
	}
	if timeStartStr != "" {
		parsed, err := time.Parse(time.RFC3339Nano, timeStartStr)
		if err != nil {
			return nil, err
		}
		tx = tx.Where("timestamp >= ?", parsed)
	}
	if timeEndStr != "" {
		parsed, err := time.Parse(time.RFC3339Nano, timeEndStr)
		if err != nil {
			return nil, err
		}
		tx = tx.Where("timestamp <= ?", parsed)
	}
	if node != "" {
		tx = tx.Where("node_name = ?", node)
	}
	if source != "" {
		tx = tx.Where("source = ?", source)
	}
	if service != "" {
		tx = tx.Where("service = ?", service)
	}
	if q != "" {
		like := "%" + escapeLike(q) + "%"
		tx = tx.Where("content LIKE ? ESCAPE '\\' OR service LIKE ? ESCAPE '\\'", like, like)
	}
	if hideHeartbeat {
		tx = tx.Where("NOT (source = 'agent' AND event = 'heartbeat')")
	}
	var entries []types.Entry
	return entries, tx.Find(&entries).Error
}

func ListEntryServices(database *gorm.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var services []string
		if err := database.Model(&types.Entry{}).
			Distinct().
			Where("service != ''").
			Order("service ASC").
			Pluck("service", &services).Error; err != nil {
			writeError(w, http.StatusInternalServerError, "failed to list services")
			return
		}
		if services == nil {
			services = []string{}
		}

		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(map[string][]string{"services": services}); err != nil {
			log.Printf("ListEntryServices encode: %v", err)
		}
	}
}

func encodeEntryCursor(entry types.Entry) string {
	return entry.Timestamp.UTC().Format(time.RFC3339Nano) + "|" + entry.ID
}

func parseEntryCursor(cursor string) (entryCursor, error) {
	ts, id, ok := strings.Cut(cursor, "|")
	if !ok || id == "" {
		return entryCursor{}, errors.New("cursor missing delimiter")
	}
	parsedTime, err := time.Parse(time.RFC3339Nano, ts)
	if err != nil {
		return entryCursor{}, err
	}
	return entryCursor{
		Timestamp: parsedTime.UTC(),
		ID:        id,
	}, nil
}

func GetEntry(database *gorm.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id := chi.URLParam(r, "id")

		var entry types.Entry
		if err := database.First(&entry, "id = ?", id).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				writeError(w, http.StatusNotFound, "entry not found")
				return
			}
			writeError(w, http.StatusInternalServerError, "failed to fetch entry")
			return
		}

		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(entry); err != nil {
			log.Printf("GetEntry encode: %v", err)
		}
	}
}