Feat: Add database stats on search UI (#36)
This commit is contained in:
103
api/search.go
103
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)
|
||||
}
|
||||
|
||||
2
main.go
2
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())
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/@heroicons/react/solid@2.0.0/dist/index.umd.js"></script>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="container mx-auto p-6">
|
||||
<body class="bg-gray-900 text-white font-sans min-h-screen flex flex-col">
|
||||
<div class="container mx-auto p-6 flex-grow">
|
||||
<!-- Header -->
|
||||
<header class="text-center mb-10">
|
||||
<h1 class="text-4xl font-bold text-blue-400">Torrent Indexer 🇧🇷</h1>
|
||||
@@ -26,12 +26,93 @@
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="results" class="space-y-6">
|
||||
<div id="results" class="space-y-6 mb-10">
|
||||
<!-- Dynamic content will be injected here -->
|
||||
</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>
|
||||
|
||||
<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 renderTorrent(torrent) {
|
||||
return `
|
||||
@@ -100,27 +181,45 @@
|
||||
|
||||
const results = await response.json();
|
||||
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) {
|
||||
// add error element
|
||||
document.getElementById('results').innerHTML = `
|
||||
<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-gray-400 mt-2">Please try again later.</p>
|
||||
</div>
|
||||
`;
|
||||
//alert('Error fetching search results. Please try again.');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('search-btn').addEventListener('click', onSearch);
|
||||
// on enter press
|
||||
document.getElementById('search-query').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
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>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user