mirror of
https://github.com/mudler/LocalAI.git
synced 2026-02-03 11:13:31 -05:00
Compare commits
1 Commits
workaround
...
feat/stats
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebda7204e |
@@ -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
|
||||
|
||||
61
core/http/endpoints/localai/settings.go
Normal file
61
core/http/endpoints/localai/settings.go
Normal 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)
|
||||
}
|
||||
}
|
||||
174
core/http/middleware/metrics.go
Normal file
174
core/http/middleware/metrics.go
Normal 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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
609
core/http/views/settings.html
Normal file
609
core/http/views/settings.html
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user