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())
}
// OpenTelemetry metrics for Prometheus export
if !application.ApplicationConfig().DisableMetrics {
metricsService, err := services.NewLocalAIMetricsService()
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
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.RegisterLocalAIRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
routes.RegisterOpenAIRoutes(router, requestExtractor, application)
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
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.RegisterJINARoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
// 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")
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)

View File

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

View File

@@ -18,7 +18,7 @@ import (
)
// 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)
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">
<i class="fas fa-home text-[#38BDF8] mr-2 group-hover:scale-110 transition-transform"></i>Home
</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">
<i class="fas fa-brain text-[#38BDF8] mr-2 group-hover:scale-110 transition-transform"></i>Models
</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">
<i class="fas fa-home text-[#38BDF8] mr-3 w-5 text-center"></i>Home
</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">
<i class="fas fa-brain text-[#38BDF8] mr-3 w-5 text-center"></i>Models
</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 (
"context"
"sync"
"time"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
@@ -10,6 +12,315 @@ import (
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 {
Meter metric.Meter
ApiTimeMetric metric.Float64Histogram
@@ -23,7 +334,7 @@ func (m *LocalAIMetricsService) ObserveAPICall(method string, path string, durat
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.
func NewLocalAIMetricsService() (*LocalAIMetricsService, error) {
exporter, err := prometheus.New()