Feat: Add database stats on search UI (#36)

This commit is contained in:
2025-07-23 18:01:44 -03:00
committed by GitHub
parent 20b054f331
commit a999f065da
4 changed files with 289 additions and 9 deletions

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"time"
meilisearch "github.com/felipemarinho97/torrent-indexer/search" meilisearch "github.com/felipemarinho97/torrent-indexer/search"
) )
@@ -13,6 +14,23 @@ type MeilisearchHandler struct {
Module *meilisearch.SearchIndexer 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. // NewMeilisearchHandler creates a new instance of MeilisearchHandler.
func NewMeilisearchHandler(module *meilisearch.SearchIndexer) *MeilisearchHandler { func NewMeilisearchHandler(module *meilisearch.SearchIndexer) *MeilisearchHandler {
return &MeilisearchHandler{Module: module} 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) 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)
}

View File

@@ -54,6 +54,8 @@ func main() {
indexerMux.HandleFunc("/indexers/torrent-dos-filmes", indexers.HandlerTorrentDosFilmesIndexer) indexerMux.HandleFunc("/indexers/torrent-dos-filmes", indexers.HandlerTorrentDosFilmesIndexer)
indexerMux.HandleFunc("/indexers/manual", indexers.HandlerManualIndexer) indexerMux.HandleFunc("/indexers/manual", indexers.HandlerManualIndexer)
indexerMux.HandleFunc("/search", search.SearchTorrentHandler) 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)))) indexerMux.Handle("/ui/", http.StripPrefix("/ui/", http.FileServer(http.FS(public.UIFiles))))
metricsMux.Handle("/metrics", promhttp.Handler()) metricsMux.Handle("/metrics", promhttp.Handler())

View File

@@ -9,8 +9,8 @@
<script src="https://cdn.jsdelivr.net/npm/@heroicons/react/solid@2.0.0/dist/index.umd.js"></script> <script src="https://cdn.jsdelivr.net/npm/@heroicons/react/solid@2.0.0/dist/index.umd.js"></script>
</head> </head>
<body class="bg-gray-900 text-white font-sans"> <body class="bg-gray-900 text-white font-sans min-h-screen flex flex-col">
<div class="container mx-auto p-6"> <div class="container mx-auto p-6 flex-grow">
<!-- Header --> <!-- Header -->
<header class="text-center mb-10"> <header class="text-center mb-10">
<h1 class="text-4xl font-bold text-blue-400">Torrent Indexer 🇧🇷</h1> <h1 class="text-4xl font-bold text-blue-400">Torrent Indexer 🇧🇷</h1>
@@ -24,14 +24,95 @@
<button id="search-btn" <button id="search-btn"
class="ml-4 px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-md font-bold text-white">Search</button> class="ml-4 px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-md font-bold text-white">Search</button>
</div> </div>
<!-- Results Section --> <!-- Results Section -->
<div id="results" class="space-y-6"> <div id="results" class="space-y-6 mb-10">
<!-- Dynamic content will be injected here --> <!-- Dynamic content will be injected here -->
</div> </div>
<!-- Health Warning -->
<div id="health-warning" class="hidden mb-6 p-4 bg-yellow-800 border border-yellow-600 rounded-lg">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-9 4h18a2 2 0 002-2V7a2 2 0 00-2-2H3a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="font-bold text-yellow-400">Service Warning</span>
</div>
<p class="text-yellow-200 mt-2">Search functionality may be disabled or experiencing issues. Please try again later.</p>
</div>
</div>
<!-- Stats Section -->
<div id="database-statistics" class="stats-info mt-auto mb-1 p-3 rounded text-center">
<span id="torrentStats" class="text-gray-400">Loading stats...</span>
</div> </div>
<script> <script>
// Global variables
let serviceHealthy = true;
// Function to check service health
async function checkHealth() {
try {
const response = await fetch('/search/health');
const health = await response.json();
if (response.status === 503 || health.status === 'unhealthy') {
serviceHealthy = false;
showHealthWarning();
hideDatabaseStatistics();
} else if (health.status === 'degraded') {
serviceHealthy = true; // Still operational
showHealthWarning(); // But show warning
} else {
serviceHealthy = true;
hideHealthWarning();
}
} catch (error) {
serviceHealthy = false;
showHealthWarning();
console.error('Health check failed:', error);
}
}
// Function to show health warning
function showHealthWarning() {
document.getElementById('health-warning').classList.remove('hidden');
}
// Function to hide health warning
function hideHealthWarning() {
document.getElementById('health-warning').classList.add('hidden');
}
function hideDatabaseStatistics() {
document.getElementById('database-statistics').classList.add('hidden');
}
// Function to load stats
async function loadStats() {
try {
const response = await fetch('/search/stats');
if (response.ok) {
const stats = await response.json();
const statsElement = document.getElementById('torrentStats');
const formattedStats = `
<span class="text-sm text-gray-500">
<span class="text-green-400 font-medium">${stats.numberOfDocuments?.toLocaleString()+'+' || 'N/A'}</span> indexed torrents!
</span>
`;
statsElement.innerHTML = formattedStats;
} else {
throw new Error('Failed to load stats');
}
} catch (error) {
hideDatabaseStatistics();
console.error('Stats loading failed:', error);
}
}
// Function to render a single torrent result // Function to render a single torrent result
function renderTorrent(torrent) { function renderTorrent(torrent) {
return ` return `
@@ -100,28 +181,46 @@
const results = await response.json(); const results = await response.json();
const resultsContainer = document.getElementById('results'); const resultsContainer = document.getElementById('results');
resultsContainer.innerHTML = results.map(renderTorrent).join('');
if (results.length === 0) {
resultsContainer.innerHTML = `
<div class="p-6 bg-gray-800 rounded-lg shadow-md text-center">
<p class="text-xl font-bold text-gray-400">No results found</p>
<p class="text-gray-500 mt-2">Try different search terms or check spelling</p>
</div>
`;
} else {
resultsContainer.innerHTML = results.map(renderTorrent).join('');
}
} catch (error) { } catch (error) {
// add error element
document.getElementById('results').innerHTML = ` document.getElementById('results').innerHTML = `
<div class="p-6 bg-red-800 rounded-lg shadow-md text-center"> <div class="p-6 bg-red-800 rounded-lg shadow-md text-center">
<p class="text-xl font-bold text-red-400">Error fetching search results</p> <p class="text-xl font-bold text-red-400">Error fetching search results</p>
<p class="text-gray-400 mt-2">Please try again later.</p> <p class="text-gray-400 mt-2">Please try again later.</p>
</div> </div>
`; `;
//alert('Error fetching search results. Please try again.');
console.error(error); console.error(error);
} }
} }
// Event listeners
document.getElementById('search-btn').addEventListener('click', onSearch); document.getElementById('search-btn').addEventListener('click', onSearch);
// on enter press
document.getElementById('search-query').addEventListener('keypress', (e) => { document.getElementById('search-query').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSearch(); onSearch();
} }
}); });
// Initialize page
document.addEventListener('DOMContentLoaded', async () => {
await checkHealth();
await loadStats();
// Refresh health and stats periodically
setInterval(checkHealth, 30000); // Check health every 30 seconds
setInterval(loadStats, 60000); // Update stats every minute
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -19,6 +19,18 @@ type SearchIndexer struct {
IndexName string 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. // NewSearchIndexer creates a new instance of SearchIndexer.
func NewSearchIndexer(baseURL, apiKey, indexName string) *SearchIndexer { func NewSearchIndexer(baseURL, apiKey, indexName string) *SearchIndexer {
return &SearchIndexer{ return &SearchIndexer{
@@ -145,3 +157,67 @@ func (t *SearchIndexer) SearchTorrent(query string, limit int) ([]schema.Indexed
return result.Hits, nil 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
}