Compare commits

...

1 Commits

Author SHA1 Message Date
Ettore Di Giacinto
eebda7204e feat(ui): add front-page stats
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-10-28 15:58:00 +01:00
10 changed files with 1713 additions and 520 deletions

View File

@@ -128,6 +128,7 @@ func API(application *application.Application) (*fiber.App, error) {
router.Use(recover.New()) router.Use(recover.New())
} }
// OpenTelemetry metrics for Prometheus export
if !application.ApplicationConfig().DisableMetrics { if !application.ApplicationConfig().DisableMetrics {
metricsService, err := services.NewLocalAIMetricsService() metricsService, err := services.NewLocalAIMetricsService()
if err != nil { if err != nil {
@@ -141,6 +142,7 @@ func API(application *application.Application) (*fiber.App, error) {
}) })
} }
} }
// Health Checks should always be exempt from auth, so register these first // Health Checks should always be exempt from auth, so register these first
routes.HealthRoutes(router) routes.HealthRoutes(router)
@@ -202,12 +204,28 @@ func API(application *application.Application) (*fiber.App, error) {
routes.RegisterElevenLabsRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig()) routes.RegisterElevenLabsRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
routes.RegisterLocalAIRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService()) routes.RegisterLocalAIRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
routes.RegisterOpenAIRoutes(router, requestExtractor, application) routes.RegisterOpenAIRoutes(router, requestExtractor, application)
if !application.ApplicationConfig().DisableWebUI { if !application.ApplicationConfig().DisableWebUI {
// Create metrics store for tracking usage (before API routes registration)
metricsStore := services.NewInMemoryMetricsStore()
// Add metrics middleware BEFORE API routes so it can intercept them
router.Use(middleware.MetricsMiddleware(metricsStore))
// Register cleanup on shutdown
router.Hooks().OnShutdown(func() error {
metricsStore.Stop()
log.Info().Msg("Metrics store stopped")
return nil
})
// Create opcache for tracking UI operations // Create opcache for tracking UI operations
opcache := services.NewOpCache(application.GalleryService()) opcache := services.NewOpCache(application.GalleryService())
routes.RegisterUIAPIRoutes(router, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), opcache) routes.RegisterUIAPIRoutes(router, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, metricsStore)
routes.RegisterUIRoutes(router, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService()) routes.RegisterUIRoutes(router, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
} }
routes.RegisterJINARoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig()) routes.RegisterJINARoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
// Define a custom 404 handler // Define a custom 404 handler

View File

@@ -0,0 +1,61 @@
package localai
import (
"github.com/gofiber/fiber/v2"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/utils"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/model"
)
// SettingsEndpoint handles the settings page which shows detailed model/backend management
func SettingsEndpoint(appConfig *config.ApplicationConfig,
cl *config.ModelConfigLoader, ml *model.ModelLoader, opcache *services.OpCache) func(*fiber.Ctx) error {
return func(c *fiber.Ctx) error {
modelConfigs := cl.GetAllModelsConfigs()
galleryConfigs := map[string]*gallery.ModelConfig{}
installedBackends, err := gallery.ListSystemBackends(appConfig.SystemState)
if err != nil {
return err
}
for _, m := range modelConfigs {
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
if err != nil {
continue
}
galleryConfigs[m.Name] = cfg
}
loadedModels := ml.ListLoadedModels()
loadedModelsMap := map[string]bool{}
for _, m := range loadedModels {
loadedModelsMap[m.ID] = true
}
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
// Get model statuses to display in the UI the operation in progress
processingModels, taskTypes := opcache.GetStatus()
summary := fiber.Map{
"Title": "LocalAI - Settings & Management",
"Version": internal.PrintableVersion(),
"BaseURL": utils.BaseURL(c),
"Models": modelsWithoutConfig,
"ModelsConfig": modelConfigs,
"GalleryConfig": galleryConfigs,
"ApplicationConfig": appConfig,
"ProcessingModels": processingModels,
"TaskTypes": taskTypes,
"LoadedModels": loadedModelsMap,
"InstalledBackends": installedBackends,
}
// Render settings page
return c.Render("views/settings", summary)
}
}

View File

@@ -0,0 +1,174 @@
package middleware
import (
"encoding/json"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/mudler/LocalAI/core/services"
"github.com/rs/zerolog/log"
)
// MetricsMiddleware creates a middleware that tracks API usage metrics
// Note: Uses CONTEXT_LOCALS_KEY_MODEL_NAME constant defined in request.go
func MetricsMiddleware(metricsStore services.MetricsStore) fiber.Handler {
return func(c *fiber.Ctx) error {
path := c.Path()
// Skip tracking for UI routes, static files, and non-API endpoints
if shouldSkipMetrics(path) {
return c.Next()
}
// Record start time
start := time.Now()
// Get endpoint category
endpoint := categorizeEndpoint(path)
// Continue with the request
err := c.Next()
// Record metrics after request completes
duration := time.Since(start)
success := err == nil && c.Response().StatusCode() < 400
// Extract model name from context (set by RequestExtractor middleware)
// Use the same constant as RequestExtractor
model := "unknown"
if modelVal, ok := c.Locals(CONTEXT_LOCALS_KEY_MODEL_NAME).(string); ok && modelVal != "" {
model = modelVal
log.Debug().Str("model", model).Str("endpoint", endpoint).Msg("Recording metrics for request")
} else {
// Fallback: try to extract from path params or query
model = extractModelFromRequest(c)
log.Debug().Str("model", model).Str("endpoint", endpoint).Msg("Recording metrics for request (fallback)")
}
// Extract backend from response headers if available
backend := string(c.Response().Header.Peek("X-LocalAI-Backend"))
// Record the request
metricsStore.RecordRequest(endpoint, model, backend, success, duration)
return err
}
}
// shouldSkipMetrics determines if a request should be excluded from metrics
func shouldSkipMetrics(path string) bool {
// Skip UI routes
skipPrefixes := []string{
"/views/",
"/static/",
"/browse/",
"/chat/",
"/text2image/",
"/tts/",
"/talk/",
"/models/edit/",
"/import-model",
"/settings",
"/api/models", // UI API endpoints
"/api/backends", // UI API endpoints
"/api/operations", // UI API endpoints
"/api/p2p", // UI API endpoints
"/api/metrics", // Metrics API itself
}
for _, prefix := range skipPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
// Also skip root path and other UI pages
if path == "/" || path == "/index" {
return true
}
return false
}
// categorizeEndpoint maps request paths to friendly endpoint categories
func categorizeEndpoint(path string) string {
// OpenAI-compatible endpoints
if strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/chat/completions") {
return "chat"
}
if strings.HasPrefix(path, "/v1/completions") || strings.HasPrefix(path, "/completions") {
return "completions"
}
if strings.HasPrefix(path, "/v1/embeddings") || strings.HasPrefix(path, "/embeddings") {
return "embeddings"
}
if strings.HasPrefix(path, "/v1/images/generations") || strings.HasPrefix(path, "/images/generations") {
return "image-generation"
}
if strings.HasPrefix(path, "/v1/audio/transcriptions") || strings.HasPrefix(path, "/audio/transcriptions") {
return "transcriptions"
}
if strings.HasPrefix(path, "/v1/audio/speech") || strings.HasPrefix(path, "/audio/speech") {
return "text-to-speech"
}
if strings.HasPrefix(path, "/v1/models") || strings.HasPrefix(path, "/models") {
return "models"
}
// LocalAI-specific endpoints
if strings.HasPrefix(path, "/v1/internal") {
return "internal"
}
if strings.Contains(path, "/tts") {
return "text-to-speech"
}
if strings.Contains(path, "/stt") || strings.Contains(path, "/whisper") {
return "speech-to-text"
}
if strings.Contains(path, "/sound-generation") {
return "sound-generation"
}
// Default to the first path segment
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) > 0 {
return parts[0]
}
return "unknown"
}
// extractModelFromRequest attempts to extract the model name from the request
func extractModelFromRequest(c *fiber.Ctx) string {
// Try query parameter first
model := c.Query("model")
if model != "" {
return model
}
// Try to extract from JSON body for POST requests
if c.Method() == fiber.MethodPost {
// Read body
bodyBytes := c.Body()
if len(bodyBytes) > 0 {
// Parse JSON
var reqBody map[string]interface{}
if err := json.Unmarshal(bodyBytes, &reqBody); err == nil {
if modelVal, ok := reqBody["model"]; ok {
if modelStr, ok := modelVal.(string); ok {
return modelStr
}
}
}
}
}
// Try path parameter for endpoints like /models/:model
model = c.Params("model")
if model != "" {
return model
}
return "unknown"
}

View File

@@ -127,6 +127,10 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
log.Debug().Str("context localModelName", localModelName).Msg("overriding empty model name in request body with value found earlier in middleware chain") log.Debug().Str("context localModelName", localModelName).Msg("overriding empty model name in request body with value found earlier in middleware chain")
input.ModelName(&localModelName) input.ModelName(&localModelName)
} }
} else {
// Update context locals with the model name from the request body
// This ensures downstream middleware (like metrics) can access it
ctx.Locals(CONTEXT_LOCALS_KEY_MODEL_NAME, input.ModelName(nil))
} }
cfg, err := re.modelConfigLoader.LoadModelConfigFileByNameDefaultOptions(input.ModelName(nil), re.applicationConfig) cfg, err := re.modelConfigLoader.LoadModelConfigFileByNameDefaultOptions(input.ModelName(nil), re.applicationConfig)

View File

@@ -23,6 +23,9 @@ func RegisterUIRoutes(app *fiber.App,
app.Get("/", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps)) app.Get("/", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
// Settings page - detailed model/backend management
app.Get("/settings", localai.SettingsEndpoint(appConfig, cl, ml, processingOps))
// P2P // P2P
app.Get("/p2p", func(c *fiber.Ctx) error { app.Get("/p2p", func(c *fiber.Ctx) error {
summary := fiber.Map{ summary := fiber.Map{

View File

@@ -18,7 +18,7 @@ import (
) )
// RegisterUIAPIRoutes registers JSON API routes for the web UI // RegisterUIAPIRoutes registers JSON API routes for the web UI
func RegisterUIAPIRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) { func RegisterUIAPIRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache, metricsStore services.MetricsStore) {
// Operations API - Get all current operations (models + backends) // Operations API - Get all current operations (models + backends)
app.Get("/api/operations", func(c *fiber.Ctx) error { app.Get("/api/operations", func(c *fiber.Ctx) error {
@@ -716,4 +716,104 @@ func RegisterUIAPIRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig
}, },
}) })
}) })
// Metrics API endpoints
if metricsStore != nil {
// Get metrics summary
app.Get("/api/metrics/summary", func(c *fiber.Ctx) error {
endpointStats := metricsStore.GetEndpointStats()
modelStats := metricsStore.GetModelStats()
backendStats := metricsStore.GetBackendStats()
// Get top 5 models
type modelStat struct {
Name string `json:"name"`
Count int64 `json:"count"`
}
topModels := make([]modelStat, 0)
for model, count := range modelStats {
topModels = append(topModels, modelStat{Name: model, Count: count})
}
sort.Slice(topModels, func(i, j int) bool {
return topModels[i].Count > topModels[j].Count
})
if len(topModels) > 5 {
topModels = topModels[:5]
}
// Get top 5 endpoints
type endpointStat struct {
Name string `json:"name"`
Count int64 `json:"count"`
}
topEndpoints := make([]endpointStat, 0)
for endpoint, count := range endpointStats {
topEndpoints = append(topEndpoints, endpointStat{Name: endpoint, Count: count})
}
sort.Slice(topEndpoints, func(i, j int) bool {
return topEndpoints[i].Count > topEndpoints[j].Count
})
if len(topEndpoints) > 5 {
topEndpoints = topEndpoints[:5]
}
return c.JSON(fiber.Map{
"totalRequests": metricsStore.GetTotalRequests(),
"successRate": metricsStore.GetSuccessRate(),
"topModels": topModels,
"topEndpoints": topEndpoints,
"topBackends": backendStats,
})
})
// Get endpoint statistics
app.Get("/api/metrics/endpoints", func(c *fiber.Ctx) error {
stats := metricsStore.GetEndpointStats()
return c.JSON(fiber.Map{
"endpoints": stats,
})
})
// Get model statistics
app.Get("/api/metrics/models", func(c *fiber.Ctx) error {
stats := metricsStore.GetModelStats()
return c.JSON(fiber.Map{
"models": stats,
})
})
// Get backend statistics
app.Get("/api/metrics/backends", func(c *fiber.Ctx) error {
stats := metricsStore.GetBackendStats()
return c.JSON(fiber.Map{
"backends": stats,
})
})
// Get time series data
app.Get("/api/metrics/timeseries", func(c *fiber.Ctx) error {
// Default to last 24 hours
hours := 24
if hoursParam := c.Query("hours"); hoursParam != "" {
if h, err := strconv.Atoi(hoursParam); err == nil && h > 0 {
hours = h
}
}
timeSeries := metricsStore.GetRequestsOverTime(hours)
return c.JSON(fiber.Map{
"timeseries": timeSeries,
"hours": hours,
})
})
// Reset metrics (optional - for testing/admin purposes)
app.Post("/api/metrics/reset", func(c *fiber.Ctx) error {
metricsStore.Reset()
return c.JSON(fiber.Map{
"success": true,
"message": "Metrics reset successfully",
})
})
}
} }

View File

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,9 @@
<a href="./" class="text-[#94A3B8] hover:text-[#E5E7EB] px-3 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[#1E293B] hover:shadow-[0_0_12px_rgba(56,189,248,0.15)] flex items-center group"> <a href="./" class="text-[#94A3B8] hover:text-[#E5E7EB] px-3 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[#1E293B] hover:shadow-[0_0_12px_rgba(56,189,248,0.15)] flex items-center group">
<i class="fas fa-home text-[#38BDF8] mr-2 group-hover:scale-110 transition-transform"></i>Home <i class="fas fa-home text-[#38BDF8] mr-2 group-hover:scale-110 transition-transform"></i>Home
</a> </a>
<a href="settings" class="text-[#94A3B8] hover:text-[#E5E7EB] px-3 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[#1E293B] hover:shadow-[0_0_12px_rgba(56,189,248,0.15)] flex items-center group">
<i class="fas fa-cog text-[#8B5CF6] mr-2 group-hover:scale-110 transition-transform"></i>Settings
</a>
<a href="browse/" class="text-[#94A3B8] hover:text-[#E5E7EB] px-3 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[#1E293B] hover:shadow-[0_0_12px_rgba(56,189,248,0.15)] flex items-center group"> <a href="browse/" class="text-[#94A3B8] hover:text-[#E5E7EB] px-3 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[#1E293B] hover:shadow-[0_0_12px_rgba(56,189,248,0.15)] flex items-center group">
<i class="fas fa-brain text-[#38BDF8] mr-2 group-hover:scale-110 transition-transform"></i>Models <i class="fas fa-brain text-[#38BDF8] mr-2 group-hover:scale-110 transition-transform"></i>Models
</a> </a>
@@ -55,6 +58,9 @@
<a href="./" class="block text-[#94A3B8] hover:text-[#E5E7EB] hover:bg-[#1E293B] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center"> <a href="./" class="block text-[#94A3B8] hover:text-[#E5E7EB] hover:bg-[#1E293B] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center">
<i class="fas fa-home text-[#38BDF8] mr-3 w-5 text-center"></i>Home <i class="fas fa-home text-[#38BDF8] mr-3 w-5 text-center"></i>Home
</a> </a>
<a href="settings" class="block text-[#94A3B8] hover:text-[#E5E7EB] hover:bg-[#1E293B] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center">
<i class="fas fa-cog text-[#8B5CF6] mr-3 w-5 text-center"></i>Settings
</a>
<a href="browse/" class="block text-[#94A3B8] hover:text-[#E5E7EB] hover:bg-[#1E293B] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center"> <a href="browse/" class="block text-[#94A3B8] hover:text-[#E5E7EB] hover:bg-[#1E293B] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center">
<i class="fas fa-brain text-[#38BDF8] mr-3 w-5 text-center"></i>Models <i class="fas fa-brain text-[#38BDF8] mr-3 w-5 text-center"></i>Models
</a> </a>

View File

@@ -0,0 +1,609 @@
<!DOCTYPE html>
<html lang="en">
{{template "views/partials/head" .}}
<body class="bg-[#101827] text-[#E5E7EB]">
<div class="flex flex-col min-h-screen" x-data="indexDashboard()">
{{template "views/partials/navbar" .}}
<!-- Notifications -->
<div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;">
<template x-for="notification in notifications" :key="notification.id">
<div x-show="true"
x-transition:enter="transform ease-out duration-300 transition"
x-transition:enter-start="translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transform ease-in duration-200 transition"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="translate-x-full opacity-0"
:class="notification.type === 'error' ? 'bg-red-500' : 'bg-green-500'"
class="rounded-lg shadow-xl p-4 text-white flex items-start space-x-3">
<div class="flex-shrink-0">
<i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium break-words" x-text="notification.message"></p>
</div>
<button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</template>
</div>
<div class="container mx-auto px-4 py-8 flex-grow">
<!-- Hero Section -->
<div class="relative bg-[#1E293B] border border-[#38BDF8]/20 rounded-3xl shadow-2xl shadow-[#38BDF8]/10 p-8 mb-12 overflow-hidden">
<!-- Background Pattern -->
<div class="absolute inset-0 opacity-10">
<div class="absolute inset-0 bg-gradient-to-r from-[#38BDF8]/20 to-[#8B5CF6]/20"></div>
<div class="absolute top-0 left-0 w-full h-full" style="background-image: radial-gradient(circle at 1px 1px, rgba(56,189,248,0.15) 1px, transparent 0); background-size: 20px 20px;"></div>
</div>
<div class="relative max-w-5xl mx-auto text-center">
<h1 class="text-5xl md:text-6xl font-bold text-[#E5E7EB] mb-6">
<span class="bg-clip-text text-transparent bg-gradient-to-r from-[#38BDF8] via-[#8B5CF6] to-[#38BDF8]">
Settings & Management
</span>
</h1>
<p class="text-xl md:text-2xl text-[#94A3B8] mb-8 font-light">Manage your models, backends, and system configuration</p>
<div class="flex flex-wrap justify-center gap-4">
<a href="/"
class="group relative inline-flex items-center bg-gray-600 hover:bg-gray-700 text-white py-3 px-8 rounded-xl font-semibold transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-[0_0_20px_rgba(75,85,99,0.4)]">
<i class="fas fa-home mr-3 text-lg"></i>
<span>Back to Dashboard</span>
<i class="fas fa-arrow-left ml-3 opacity-70 group-hover:opacity-100 transition-opacity"></i>
</a>
<a href="https://localai.io" target="_blank"
class="group relative inline-flex items-center bg-[#38BDF8] hover:bg-[#38BDF8]/90 text-[#101827] py-3 px-8 rounded-xl font-semibold transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-[0_0_20px_rgba(56,189,248,0.4)]">
<i class="fas fa-book-reader mr-3 text-lg"></i>
<span>Documentation</span>
<i class="fas fa-external-link-alt ml-3 text-sm opacity-70 group-hover:opacity-100 transition-opacity"></i>
</a>
<a href="browse"
class="group relative inline-flex items-center bg-[#8B5CF6] hover:bg-[#8B5CF6]/90 text-white py-3 px-8 rounded-xl font-semibold transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-[0_0_20px_rgba(139,92,246,0.4)]">
<i class="fas fa-images mr-3 text-lg"></i>
<span>Model Gallery</span>
<i class="fas fa-arrow-right ml-3 opacity-0 group-hover:opacity-100 group-hover:translate-x-1 transition-all duration-300"></i>
</a>
<a href="/import-model"
class="group relative inline-flex items-center bg-green-600 hover:bg-green-700 text-white py-3 px-8 rounded-xl font-semibold transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-[0_0_20px_rgba(34,197,94,0.4)]">
<i class="fas fa-plus mr-3 text-lg"></i>
<span>Import Model</span>
<i class="fas fa-upload ml-3 opacity-70 group-hover:opacity-100 transition-opacity"></i>
</a>
<button id="reload-models-btn"
class="group relative inline-flex items-center bg-orange-600 hover:bg-orange-700 text-white py-3 px-8 rounded-xl font-semibold transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-[0_0_20px_rgba(234,88,12,0.4)]">
<i class="fas fa-sync-alt mr-3 text-lg"></i>
<span>Update Models</span>
<i class="fas fa-refresh ml-3 opacity-70 group-hover:opacity-100 transition-opacity"></i>
</button>
</div>
</div>
</div>
<!-- Models Section -->
<div class="models mt-8">
{{template "views/partials/inprogress" .}}
{{ if eq (len .ModelsConfig) 0 }}
<!-- No Models State -->
<div class="relative bg-[#1E293B]/80 border border-[#38BDF8]/20 rounded-2xl p-12 shadow-xl backdrop-blur-sm">
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-yellow-500/5 to-orange-500/5"></div>
<div class="relative text-center max-w-4xl mx-auto">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-yellow-500/10 border border-yellow-500/20 mb-6">
<i class="text-yellow-400 text-3xl fas fa-robot"></i>
</div>
<h2 class="text-3xl md:text-4xl font-bold text-[#E5E7EB] mb-6">No models installed yet</h2>
<p class="text-xl text-[#94A3B8] mb-8 leading-relaxed">Get started by installing models from the gallery or check our documentation for guidance</p>
<div class="flex flex-wrap justify-center gap-4 mb-8">
<a href="browse" class="inline-flex items-center bg-[#38BDF8] hover:bg-[#38BDF8]/90 text-[#101827] py-3 px-6 rounded-xl font-semibold transition-all duration-300 transform hover:scale-105 hover:shadow-[0_0_20px_rgba(56,189,248,0.4)]">
<i class="fas fa-images mr-2"></i>
Browse Gallery
</a>
<a href="https://localai.io/basics/getting_started/" class="inline-flex items-center bg-[#1E293B] hover:bg-[#1E293B]/80 border border-[#38BDF8]/20 text-[#E5E7EB] py-3 px-6 rounded-xl font-semibold transition-all duration-300 transform hover:scale-105">
<i class="fas fa-book mr-2"></i>
Documentation
</a>
</div>
{{ if ne (len .Models) 0 }}
<div class="mt-12 pt-8 border-t border-[#38BDF8]/20">
<h3 class="text-2xl font-bold text-[#E5E7EB] mb-6">Detected Model Files</h3>
<p class="text-[#94A3B8] mb-6">These models were found but don't have configuration files yet</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{{ range .Models }}
<div class="bg-[#101827] border border-[#38BDF8]/20 rounded-xl p-4 flex items-center hover:border-[#38BDF8]/50 transition-all duration-300 hover:shadow-[0_0_12px_rgba(56,189,248,0.15)]">
<div class="w-10 h-10 rounded-lg bg-[#1E293B] flex items-center justify-center mr-3">
<i class="fas fa-brain text-[#38BDF8]"></i>
</div>
<div class="flex-1">
<p class="font-semibold text-[#E5E7EB] truncate">{{.}}</p>
<p class="text-xs text-[#94A3B8]">No configuration</p>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{ else }}
<!-- Models Grid -->
{{ $modelsN := len .ModelsConfig}}
{{ $modelsN = add $modelsN (len .Models)}}
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between">
<div class="mb-4 md:mb-0">
<h2 class="text-3xl md:text-4xl font-bold text-[#E5E7EB] mb-2">
Installed Models
</h2>
<p class="text-[#94A3B8]">
<span class="text-[#38BDF8] font-semibold">{{$modelsN}}</span> model{{if gt $modelsN 1}}s{{end}} ready to use
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{{$galleryConfig:=.GalleryConfig}}
{{ $loadedModels := .LoadedModels }}
{{$noicon:="https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg"}}
{{ range .ModelsConfig }}
{{ $backendCfg := . }}
{{ $cfg:= index $galleryConfig .Name}}
<div class="group relative bg-[#1E293B] border border-[#38BDF8]/20 rounded-2xl overflow-hidden transition-all duration-500 hover:shadow-[0_0_20px_rgba(56,189,248,0.2)] hover:-translate-y-2 hover:border-[#38BDF8]/50">
<!-- Card Header -->
<div class="relative p-6 border-b border-[#101827]">
<div class="flex items-start space-x-4">
<div class="relative w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-[#101827] flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<img {{ if and $cfg $cfg.Icon }}
src="{{$cfg.Icon}}"
{{ else }}
src="{{$noicon}}"
{{ end }}
class="w-full h-full object-contain"
alt="{{.Name}} icon"
>
{{ if index $loadedModels .Name }}
<div class="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-[#1E293B] animate-pulse"></div>
{{ end }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="font-bold text-xl text-[#E5E7EB] truncate group-hover:text-[#38BDF8] transition-colors">{{.Name}}</h3>
</div>
<div class="mt-2 flex flex-wrap gap-2">
{{ if .Backend }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-[#38BDF8]/20 text-[#38BDF8] border border-[#38BDF8]/30">
<i class="fas fa-cog mr-1"></i>{{.Backend}}
</span>
{{ else }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-500/10 text-yellow-300 border border-yellow-500/30">
<i class="fas fa-magic mr-1"></i>Auto
</span>
{{ end }}
{{ if and $backendCfg (or (ne $backendCfg.MCP.Servers "") (ne $backendCfg.MCP.Stdio "")) }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-[#8B5CF6]/20 text-[#8B5CF6] border border-[#8B5CF6]/30">
<i class="fas fa-plug mr-1"></i>MCP
</span>
{{ end }}
{{ if index $loadedModels .Name }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-500/10 text-green-300 border border-green-500/30">
<i class="fas fa-play mr-1"></i>Running
</span>
{{ end }}
</div>
</div>
</div>
</div>
<!-- Usage Buttons -->
<div class="p-6">
<div class="flex flex-wrap gap-2 mb-4">
{{ range .KnownUsecaseStrings }}
{{ if eq . "FLAG_CHAT" }}
<a href="chat/{{$backendCfg.Name}}" class="flex-1 min-w-0 group/chat inline-flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-[#38BDF8] hover:bg-[#38BDF8]/90 text-[#101827] transition-all duration-300 transform hover:scale-105 hover:shadow-[0_0_15px_rgba(56,189,248,0.4)]">
<i class="fas fa-comment-alt mr-2 group-hover/chat:animate-bounce"></i>
Chat
</a>
{{ end }}
{{ if eq . "FLAG_IMAGE" }}
<a href="text2image/{{$backendCfg.Name}}" class="flex-1 min-w-0 group/image inline-flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-green-600 hover:bg-green-700 text-white transition-all duration-300 transform hover:scale-105 hover:shadow-[0_0_15px_rgba(34,197,94,0.4)]">
<i class="fas fa-image mr-2 group-hover/image:animate-pulse"></i>
Image
</a>
{{ end }}
{{ if eq . "FLAG_TTS" }}
<a href="tts/{{$backendCfg.Name}}" class="flex-1 min-w-0 group/tts inline-flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-[#8B5CF6] hover:bg-[#8B5CF6]/90 text-white transition-all duration-300 transform hover:scale-105 hover:shadow-[0_0_15px_rgba(139,92,246,0.4)]">
<i class="fas fa-microphone mr-2 group-hover/tts:animate-pulse"></i>
TTS
</a>
{{ end }}
{{ end }}
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center pt-4 border-t border-[#101827]">
<div class="flex gap-2">
{{ if index $loadedModels .Name }}
<button class="group/stop inline-flex items-center text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg px-3 py-2 transition-all duration-200"
data-twe-ripple-init=""
onclick="handleStopModel('{{.Name}}')">
<i class="fas fa-stop mr-2 group-hover/stop:animate-pulse"></i>Stop
</button>
{{ end }}
</div>
<div class="flex gap-2">
<a href="/models/edit/{{.Name}}"
class="group/edit inline-flex items-center text-sm font-semibold text-[#38BDF8] hover:text-[#8B5CF6] hover:bg-[#38BDF8]/10 rounded-lg px-3 py-2 transition-all duration-200">
<i class="fas fa-edit mr-2 group-hover/edit:animate-pulse"></i>Edit
</a>
<button
class="group/delete inline-flex items-center text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg px-3 py-2 transition-all duration-200"
data-twe-ripple-init=""
onclick="handleDeleteModel('{{.Name}}')">
<i class="fas fa-trash-alt mr-2 group-hover/delete:animate-bounce"></i>Delete
</button>
</div>
</div>
</div>
</div>
{{ end }}
<!-- Models without config -->
{{ range .Models }}
<div class="group relative bg-[#1E293B]/80 border border-[#38BDF8]/20 rounded-2xl overflow-hidden transition-all duration-500 hover:shadow-[0_0_15px_rgba(234,179,8,0.15)] hover:-translate-y-1 hover:border-yellow-500/30">
<div class="p-6">
<div class="flex items-start space-x-4">
<div class="w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-[#101827] flex items-center justify-center">
<i class="fas fa-brain text-2xl text-[#94A3B8]"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-xl text-[#E5E7EB] truncate mb-2">{{.}}</h3>
<div class="flex flex-wrap gap-2 mb-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-500/10 text-yellow-300 border border-yellow-500/30">
<i class="fas fa-magic mr-1"></i>Auto Backend
</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-500/10 text-orange-300 border border-orange-500/30">
<i class="fas fa-exclamation-triangle mr-1"></i>No Config
</span>
</div>
<div class="flex justify-center pt-4">
<span class="inline-flex items-center text-sm font-medium text-[#94A3B8] px-4 py-2 bg-[#101827]/50 rounded-lg">
<i class="fas fa-info-circle mr-2"></i>
Configuration required for full functionality
</span>
</div>
</div>
</div>
</div>
</div>
{{end}}
</div>
{{ end }}
</div>
<!-- Backends Section -->
<div class="mt-12">
<div class="mb-8">
<h2 class="text-3xl md:text-4xl font-bold text-[#E5E7EB] mb-2">
Installed Backends
</h2>
<p class="text-[#94A3B8]">
<span class="text-[#8B5CF6] font-semibold">{{len .InstalledBackends}}</span> backend{{if gt (len .InstalledBackends) 1}}s{{end}} ready to use
</p>
</div>
{{ if eq (len .InstalledBackends) 0 }}
<!-- No backends state -->
<div class="relative bg-[#1E293B]/80 border border-[#8B5CF6]/20 rounded-2xl p-12 shadow-xl backdrop-blur-sm">
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-purple-500/5 to-cyan-500/5"></div>
<div class="relative text-center max-w-4xl mx-auto">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-[#8B5CF6]/10 border border-[#8B5CF6]/20 mb-6">
<i class="text-[#8B5CF6] text-3xl fas fa-cogs"></i>
</div>
<h2 class="text-3xl md:text-4xl font-bold text-[#E5E7EB] mb-6">No backends installed yet</h2>
<p class="text-xl text-[#94A3B8] mb-8 leading-relaxed">Backends power your AI models. Install them from the backend gallery to get started</p>
<div class="flex flex-wrap justify-center gap-4">
<a href="/browse/backends" class="inline-flex items-center bg-[#8B5CF6] hover:bg-[#8B5CF6]/90 text-white py-3 px-6 rounded-xl font-semibold transition-all duration-300 transform hover:scale-105 hover:shadow-[0_0_20px_rgba(139,92,246,0.4)]">
<i class="fas fa-cogs mr-2"></i>
Browse Backend Gallery
</a>
<a href="https://localai.io/backends/" target="_blank" class="inline-flex items-center bg-[#1E293B] hover:bg-[#1E293B]/80 border border-[#8B5CF6]/20 text-[#E5E7EB] py-3 px-6 rounded-xl font-semibold transition-all duration-300 transform hover:scale-105">
<i class="fas fa-book mr-2"></i>
Documentation
</a>
</div>
</div>
</div>
{{ else }}
<!-- Backends Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{{ range .InstalledBackends }}
<div class="group relative bg-[#1E293B] border border-[#8B5CF6]/20 rounded-2xl overflow-hidden transition-all duration-500 hover:shadow-[0_0_20px_rgba(139,92,246,0.2)] hover:-translate-y-2 hover:border-[#8B5CF6]/50">
<!-- Card Header -->
<div class="relative p-6 border-b border-[#101827]">
<div class="flex items-start space-x-4">
<div class="w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-[#101827] flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-cog text-2xl text-[#8B5CF6]"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-xl text-[#E5E7EB] truncate mb-2 group-hover:text-[#8B5CF6] transition-colors">{{.Name}}</h3>
<div class="flex flex-wrap gap-2">
{{ if .IsSystem }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-300 border border-blue-500/30">
<i class="fas fa-shield-alt mr-1"></i>System
</span>
{{ else }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-500/10 text-green-300 border border-green-500/30">
<i class="fas fa-download mr-1"></i>User Installed
</span>
{{ end }}
{{ if .IsMeta }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-[#8B5CF6]/20 text-[#8B5CF6] border border-[#8B5CF6]/30">
<i class="fas fa-layer-group mr-1"></i>Meta
</span>
{{ end }}
</div>
</div>
</div>
</div>
<!-- Backend Details -->
<div class="p-6">
<div class="space-y-3 text-sm">
{{ if and .Metadata .Metadata.Alias }}
<div class="flex items-start">
<i class="fas fa-tag text-[#94A3B8] mr-2 mt-0.5"></i>
<div class="flex-1">
<span class="text-[#94A3B8]">Alias:</span>
<span class="text-[#E5E7EB] ml-1">{{.Metadata.Alias}}</span>
</div>
</div>
{{ end }}
{{ if and .Metadata .Metadata.InstalledAt }}
<div class="flex items-start">
<i class="fas fa-calendar text-[#94A3B8] mr-2 mt-0.5"></i>
<div class="flex-1">
<span class="text-[#94A3B8]">Installed:</span>
<span class="text-[#E5E7EB] ml-1">{{.Metadata.InstalledAt}}</span>
</div>
</div>
{{ end }}
{{ if and .Metadata .Metadata.MetaBackendFor }}
<div class="flex items-start">
<i class="fas fa-link text-[#94A3B8] mr-2 mt-0.5"></i>
<div class="flex-1">
<span class="text-[#94A3B8]">Meta backend for:</span>
<span class="text-[#8B5CF6] ml-1 font-semibold">{{.Metadata.MetaBackendFor}}</span>
</div>
</div>
{{ end }}
{{ if and .Metadata .Metadata.GalleryURL }}
<div class="flex items-start">
<i class="fas fa-globe text-[#94A3B8] mr-2 mt-0.5"></i>
<div class="flex-1">
<span class="text-[#94A3B8]">Gallery:</span>
<a href="{{.Metadata.GalleryURL}}" target="_blank" class="text-[#38BDF8] hover:text-[#38BDF8]/80 ml-1 truncate inline-block max-w-[200px] align-bottom">
{{.Metadata.GalleryURL}}
<i class="fas fa-external-link-alt text-xs ml-1"></i>
</a>
</div>
</div>
{{ end }}
<div class="flex items-start">
<i class="fas fa-folder text-[#94A3B8] mr-2 mt-0.5"></i>
<div class="flex-1">
<span class="text-[#94A3B8]">Path:</span>
<span class="text-[#E5E7EB] ml-1 text-xs font-mono truncate block">{{.RunFile}}</span>
</div>
</div>
</div>
<!-- Action Buttons -->
{{ if not .IsSystem }}
<div class="flex justify-end items-center pt-4 mt-4 border-t border-[#101827]">
<button
@click="deleteBackend('{{.Name}}')"
class="group/delete inline-flex items-center text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg px-3 py-2 transition-all duration-200">
<i class="fas fa-trash-alt mr-2 group-hover/delete:animate-bounce"></i>Delete
</button>
</div>
{{ end }}
</div>
</div>
{{end}}
</div>
{{ end }}
</div>
</div>
{{template "views/partials/footer" .}}
</div>
<script>
// Alpine.js component for index dashboard
function indexDashboard() {
return {
notifications: [],
init() {
// Initialize component
},
addNotification(message, type = 'success') {
const id = Date.now();
this.notifications.push({ id, message, type });
// Auto-dismiss after 5 seconds
setTimeout(() => this.dismissNotification(id), 5000);
},
dismissNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
},
async deleteBackend(backendName) {
if (!confirm(`Are you sure you want to delete the backend "${backendName}"?`)) {
return;
}
try {
const response = await fetch(`/api/backends/system/delete/${encodeURIComponent(backendName)}`, {
method: 'POST'
});
const data = await response.json();
if (response.ok && data.success) {
this.addNotification(`Backend "${backendName}" deleted successfully!`, 'success');
// Reload page after short delay
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
this.addNotification(`Failed to delete backend: ${data.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error deleting backend:', error);
this.addNotification(`Failed to delete backend: ${error.message}`, 'error');
}
}
}
}
async function handleStopModel(modelName) {
if (!confirm('Are you sure you wish to stop this model?')) {
return;
}
try {
const response = await fetch('/backend/shutdown', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: modelName })
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to stop model');
}
} catch (error) {
console.error('Error stopping model:', error);
alert('Failed to stop model');
}
}
async function handleDeleteModel(modelName) {
if (!confirm('Are you sure you wish to delete this model?')) {
return;
}
try {
const response = await fetch(`/api/models/delete/${encodeURIComponent(modelName)}`, {
method: 'POST'
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to delete model');
}
} catch (error) {
console.error('Error deleting model:', error);
alert('Failed to delete model');
}
}
// Handle reload models button
document.addEventListener('DOMContentLoaded', function() {
const reloadBtn = document.getElementById('reload-models-btn');
if (reloadBtn) {
reloadBtn.addEventListener('click', function() {
const button = this;
const originalText = button.querySelector('span').textContent;
const icon = button.querySelector('i');
// Show loading state
button.disabled = true;
button.querySelector('span').textContent = 'Updating...';
icon.classList.add('fa-spin');
// Make the API call
fetch('/models/reload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success state briefly
button.querySelector('span').textContent = 'Updated!';
icon.classList.remove('fa-spin', 'fa-sync-alt');
icon.classList.add('fa-check');
// Reload the page after a short delay
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
// Show error state
button.querySelector('span').textContent = 'Error!';
icon.classList.remove('fa-spin');
console.error('Failed to reload models:', data.error);
// Reset button after delay
setTimeout(() => {
button.disabled = false;
button.querySelector('span').textContent = originalText;
icon.classList.remove('fa-check');
icon.classList.add('fa-sync-alt');
}, 3000);
}
})
.catch(error => {
// Show error state
button.querySelector('span').textContent = 'Error!';
icon.classList.remove('fa-spin');
console.error('Error reloading models:', error);
// Reset button after delay
setTimeout(() => {
button.disabled = false;
button.querySelector('span').textContent = originalText;
icon.classList.remove('fa-check');
icon.classList.add('fa-sync-alt');
}, 3000);
});
});
}
});
</script>
</body>
</html>

View File

@@ -2,6 +2,8 @@ package services
import ( import (
"context" "context"
"sync"
"time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
@@ -10,6 +12,315 @@ import (
metricApi "go.opentelemetry.io/otel/sdk/metric" metricApi "go.opentelemetry.io/otel/sdk/metric"
) )
// MetricsStore is the interface for storing and retrieving metrics
// This allows for future implementations with persistence (JSON files, databases, etc.)
type MetricsStore interface {
RecordRequest(endpoint, model, backend string, success bool, duration time.Duration)
GetEndpointStats() map[string]int64
GetModelStats() map[string]int64
GetBackendStats() map[string]int64
GetRequestsOverTime(hours int) []TimeSeriesPoint
GetTotalRequests() int64
GetSuccessRate() float64
Reset()
}
// TimeSeriesPoint represents a single point in the time series
type TimeSeriesPoint struct {
Timestamp time.Time `json:"timestamp"`
Count int64 `json:"count"`
}
// RequestRecord stores individual request information
type RequestRecord struct {
Timestamp time.Time
Endpoint string
Model string
Backend string
Success bool
Duration time.Duration
}
// InMemoryMetricsStore implements MetricsStore with in-memory storage
type InMemoryMetricsStore struct {
endpoints map[string]int64
models map[string]int64
backends map[string]int64
timeSeries []RequestRecord
successCount int64
failureCount int64
mu sync.RWMutex
stopChan chan struct{}
maxRecords int // Maximum number of time series records to keep
maxMapKeys int // Maximum number of unique keys per map
pruneEvery time.Duration // How often to prune old data
}
// NewInMemoryMetricsStore creates a new in-memory metrics store
func NewInMemoryMetricsStore() *InMemoryMetricsStore {
store := &InMemoryMetricsStore{
endpoints: make(map[string]int64),
models: make(map[string]int64),
backends: make(map[string]int64),
timeSeries: make([]RequestRecord, 0),
stopChan: make(chan struct{}),
maxRecords: 10000, // Limit to 10k records (~1-2MB of memory)
maxMapKeys: 1000, // Limit to 1000 unique keys per map (~50KB per map)
pruneEvery: 5 * time.Minute, // Prune every 5 minutes instead of every request
}
// Start background pruning goroutine
go store.pruneLoop()
return store
}
// pruneLoop runs periodically to clean up old data
func (m *InMemoryMetricsStore) pruneLoop() {
ticker := time.NewTicker(m.pruneEvery)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.pruneOldData()
case <-m.stopChan:
return
}
}
}
// pruneOldData removes data older than 24 hours and enforces max record limit
func (m *InMemoryMetricsStore) pruneOldData() {
m.mu.Lock()
defer m.mu.Unlock()
cutoff := time.Now().Add(-24 * time.Hour)
newTimeSeries := make([]RequestRecord, 0, len(m.timeSeries))
for _, r := range m.timeSeries {
if r.Timestamp.After(cutoff) {
newTimeSeries = append(newTimeSeries, r)
}
}
// If still over the limit, keep only the most recent records
if len(newTimeSeries) > m.maxRecords {
// Keep the most recent maxRecords entries
newTimeSeries = newTimeSeries[len(newTimeSeries)-m.maxRecords:]
log.Warn().
Int("dropped", len(m.timeSeries)-len(newTimeSeries)).
Int("kept", len(newTimeSeries)).
Msg("Metrics store exceeded maximum records, dropping oldest entries")
}
m.timeSeries = newTimeSeries
// Also check if maps have grown too large
m.pruneMapIfNeeded("endpoints", m.endpoints, m.maxMapKeys)
m.pruneMapIfNeeded("models", m.models, m.maxMapKeys)
m.pruneMapIfNeeded("backends", m.backends, m.maxMapKeys)
}
// pruneMapIfNeeded keeps only the top N entries in a map by count
func (m *InMemoryMetricsStore) pruneMapIfNeeded(name string, mapData map[string]int64, maxKeys int) {
if len(mapData) <= maxKeys {
return
}
// Convert to slice for sorting
type kv struct {
key string
value int64
}
entries := make([]kv, 0, len(mapData))
for k, v := range mapData {
entries = append(entries, kv{k, v})
}
// Sort by value descending (keep highest counts)
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[i].value < entries[j].value {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
// Keep only top maxKeys entries
for k := range mapData {
delete(mapData, k)
}
for i := 0; i < maxKeys && i < len(entries); i++ {
mapData[entries[i].key] = entries[i].value
}
log.Warn().
Str("map", name).
Int("dropped", len(entries)-maxKeys).
Int("kept", maxKeys).
Msg("Metrics map exceeded maximum keys, keeping only top entries")
}
// Stop gracefully shuts down the metrics store
func (m *InMemoryMetricsStore) Stop() {
close(m.stopChan)
}
// RecordRequest records a new API request
func (m *InMemoryMetricsStore) RecordRequest(endpoint, model, backend string, success bool, duration time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
// Record endpoint
if endpoint != "" {
m.endpoints[endpoint]++
}
// Record model
if model != "" {
m.models[model]++
}
// Record backend
if backend != "" {
m.backends[backend]++
}
// Record success/failure
if success {
m.successCount++
} else {
m.failureCount++
}
// Add to time series
record := RequestRecord{
Timestamp: time.Now(),
Endpoint: endpoint,
Model: model,
Backend: backend,
Success: success,
Duration: duration,
}
m.timeSeries = append(m.timeSeries, record)
// Note: Pruning is done periodically by pruneLoop() to avoid overhead on every request
}
// GetEndpointStats returns request counts per endpoint
func (m *InMemoryMetricsStore) GetEndpointStats() map[string]int64 {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]int64)
for k, v := range m.endpoints {
result[k] = v
}
return result
}
// GetModelStats returns request counts per model
func (m *InMemoryMetricsStore) GetModelStats() map[string]int64 {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]int64)
for k, v := range m.models {
result[k] = v
}
return result
}
// GetBackendStats returns request counts per backend
func (m *InMemoryMetricsStore) GetBackendStats() map[string]int64 {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]int64)
for k, v := range m.backends {
result[k] = v
}
return result
}
// GetRequestsOverTime returns time series data for the specified number of hours
func (m *InMemoryMetricsStore) GetRequestsOverTime(hours int) []TimeSeriesPoint {
m.mu.RLock()
defer m.mu.RUnlock()
cutoff := time.Now().Add(-time.Duration(hours) * time.Hour)
// Group by hour
hourlyBuckets := make(map[int64]int64)
for _, record := range m.timeSeries {
if record.Timestamp.After(cutoff) {
// Round down to the hour
hourTimestamp := record.Timestamp.Truncate(time.Hour).Unix()
hourlyBuckets[hourTimestamp]++
}
}
// Convert to sorted time series
result := make([]TimeSeriesPoint, 0)
for ts, count := range hourlyBuckets {
result = append(result, TimeSeriesPoint{
Timestamp: time.Unix(ts, 0),
Count: count,
})
}
// Sort by timestamp
for i := 0; i < len(result); i++ {
for j := i + 1; j < len(result); j++ {
if result[i].Timestamp.After(result[j].Timestamp) {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
// GetTotalRequests returns the total number of requests recorded
func (m *InMemoryMetricsStore) GetTotalRequests() int64 {
m.mu.RLock()
defer m.mu.RUnlock()
return m.successCount + m.failureCount
}
// GetSuccessRate returns the percentage of successful requests
func (m *InMemoryMetricsStore) GetSuccessRate() float64 {
m.mu.RLock()
defer m.mu.RUnlock()
total := m.successCount + m.failureCount
if total == 0 {
return 0.0
}
return float64(m.successCount) / float64(total) * 100.0
}
// Reset clears all metrics
func (m *InMemoryMetricsStore) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.endpoints = make(map[string]int64)
m.models = make(map[string]int64)
m.backends = make(map[string]int64)
m.timeSeries = make([]RequestRecord, 0)
m.successCount = 0
m.failureCount = 0
}
// ============================================================================
// OpenTelemetry Metrics Service (for Prometheus export)
// ============================================================================
type LocalAIMetricsService struct { type LocalAIMetricsService struct {
Meter metric.Meter Meter metric.Meter
ApiTimeMetric metric.Float64Histogram ApiTimeMetric metric.Float64Histogram
@@ -23,7 +334,7 @@ func (m *LocalAIMetricsService) ObserveAPICall(method string, path string, durat
m.ApiTimeMetric.Record(context.Background(), duration, opts) m.ApiTimeMetric.Record(context.Background(), duration, opts)
} }
// setupOTelSDK bootstraps the OpenTelemetry pipeline. // NewLocalAIMetricsService bootstraps the OpenTelemetry pipeline for Prometheus export.
// If it does not return an error, make sure to call shutdown for proper cleanup. // If it does not return an error, make sure to call shutdown for proper cleanup.
func NewLocalAIMetricsService() (*LocalAIMetricsService, error) { func NewLocalAIMetricsService() (*LocalAIMetricsService, error) {
exporter, err := prometheus.New() exporter, err := prometheus.New()