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"
|
"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)
|
||||||
|
}
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -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())
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user