diff --git a/api/search.go b/api/search.go index 06a69a0..a19f601 100644 --- a/api/search.go +++ b/api/search.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "time" meilisearch "github.com/felipemarinho97/torrent-indexer/search" ) @@ -13,6 +14,23 @@ type MeilisearchHandler struct { Module *meilisearch.SearchIndexer } +// HealthResponse represents the health check response +type HealthResponse struct { + Status string `json:"status"` + Service string `json:"service"` + Details map[string]interface{} `json:"details,omitempty"` + Timestamp string `json:"timestamp"` +} + +// StatsResponse represents the stats endpoint response +type StatsResponse struct { + Status string `json:"status"` + NumberOfDocuments int64 `json:"numberOfDocuments"` + IsIndexing bool `json:"isIndexing"` + FieldDistribution map[string]int64 `json:"fieldDistribution"` + Service string `json:"service"` +} + // NewMeilisearchHandler creates a new instance of MeilisearchHandler. func NewMeilisearchHandler(module *meilisearch.SearchIndexer) *MeilisearchHandler { return &MeilisearchHandler{Module: module} @@ -53,3 +71,88 @@ func (h *MeilisearchHandler) SearchTorrentHandler(w http.ResponseWriter, r *http http.Error(w, "Failed to encode response", http.StatusInternalServerError) } } + +// HealthHandler provides a health check endpoint for Meilisearch. +func (h *MeilisearchHandler) HealthHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + // Check if Meilisearch is healthy + isHealthy := h.Module.IsHealthy() + + response := HealthResponse{ + Service: "meilisearch", + Timestamp: getCurrentTimestamp(), + } + + if isHealthy { + // Try to get additional stats for more detailed health info + stats, err := h.Module.GetStats() + if err == nil { + response.Status = "healthy" + response.Details = map[string]interface{}{ + "documents": stats.NumberOfDocuments, + "indexing": stats.IsIndexing, + } + w.WriteHeader(http.StatusOK) + } else { + // Service is up but can't get stats + response.Status = "degraded" + response.Details = map[string]interface{}{ + "error": "Could not retrieve stats", + } + w.WriteHeader(http.StatusOK) + } + } else { + // Service is down + response.Status = "unhealthy" + w.WriteHeader(http.StatusServiceUnavailable) + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// StatsHandler provides detailed statistics about the Meilisearch index. +func (h *MeilisearchHandler) StatsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + // Get detailed stats from Meilisearch + stats, err := h.Module.GetStats() + if err != nil { + // Check if it's a connectivity issue + if !h.Module.IsHealthy() { + http.Error(w, "Meilisearch service is unavailable", http.StatusServiceUnavailable) + return + } + http.Error(w, "Failed to retrieve statistics", http.StatusInternalServerError) + return + } + + response := StatsResponse{ + Status: "healthy", + Service: "meilisearch", + NumberOfDocuments: stats.NumberOfDocuments, + IsIndexing: stats.IsIndexing, + FieldDistribution: stats.FieldDistribution, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// getCurrentTimestamp returns the current timestamp in RFC3339 format +func getCurrentTimestamp() string { + return time.Now().Format(time.RFC3339) +} diff --git a/main.go b/main.go index d31ee4e..00c2e87 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,8 @@ func main() { indexerMux.HandleFunc("/indexers/torrent-dos-filmes", indexers.HandlerTorrentDosFilmesIndexer) indexerMux.HandleFunc("/indexers/manual", indexers.HandlerManualIndexer) indexerMux.HandleFunc("/search", search.SearchTorrentHandler) + indexerMux.HandleFunc("/search/health", search.HealthHandler) + indexerMux.HandleFunc("/search/stats", search.StatsHandler) indexerMux.Handle("/ui/", http.StripPrefix("/ui/", http.FileServer(http.FS(public.UIFiles)))) metricsMux.Handle("/metrics", promhttp.Handler()) diff --git a/public/index.html b/public/index.html index 102347d..999b879 100644 --- a/public/index.html +++ b/public/index.html @@ -9,8 +9,8 @@ - -
+ +

Torrent Indexer 🇧🇷

@@ -24,14 +24,95 @@
- + -
+
+ + + + +
+ +
+ Loading stats...
- + \ No newline at end of file diff --git a/search/meilisearch.go b/search/meilisearch.go index 9112e72..9b77a1d 100644 --- a/search/meilisearch.go +++ b/search/meilisearch.go @@ -19,6 +19,18 @@ type SearchIndexer struct { IndexName string } +// IndexStats represents statistics about the Meilisearch index +type IndexStats struct { + NumberOfDocuments int64 `json:"numberOfDocuments"` + IsIndexing bool `json:"isIndexing"` + FieldDistribution map[string]int64 `json:"fieldDistribution"` +} + +// HealthStatus represents the health status of Meilisearch +type HealthStatus struct { + Status string `json:"status"` +} + // NewSearchIndexer creates a new instance of SearchIndexer. func NewSearchIndexer(baseURL, apiKey, indexName string) *SearchIndexer { return &SearchIndexer{ @@ -145,3 +157,67 @@ func (t *SearchIndexer) SearchTorrent(query string, limit int) ([]schema.Indexed return result.Hits, nil } + +// GetStats retrieves statistics about the Meilisearch index including document count. +// This method can be used for health checks and monitoring. +func (t *SearchIndexer) GetStats() (*IndexStats, error) { + url := fmt.Sprintf("%s/indexes/%s/stats", t.BaseURL, t.IndexName) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if t.APIKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.APIKey)) + } + + resp, err := t.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get stats: status %d, body: %s", resp.StatusCode, body) + } + + var stats IndexStats + if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { + return nil, fmt.Errorf("failed to parse stats response: %w", err) + } + + return &stats, nil +} + +// IsHealthy checks if Meilisearch is available and responsive. +// Returns true if the service is healthy, false otherwise. +func (t *SearchIndexer) IsHealthy() bool { + url := fmt.Sprintf("%s/health", t.BaseURL) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false + } + + // Use a shorter timeout for health checks + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} + +// GetDocumentCount returns the number of indexed documents. +// This is a convenience method that extracts just the document count from stats. +func (t *SearchIndexer) GetDocumentCount() (int64, error) { + stats, err := t.GetStats() + if err != nil { + return 0, err + } + return stats.NumberOfDocuments, nil +}