Skip to content

Commit eebda72

Browse files
committed
feat(ui): add front-page stats
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent ba1b8e7 commit eebda72

File tree

10 files changed

+1701
-508
lines changed

10 files changed

+1701
-508
lines changed

core/http/app.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ func API(application *application.Application) (*fiber.App, error) {
128128
router.Use(recover.New())
129129
}
130130

131+
// OpenTelemetry metrics for Prometheus export
131132
if !application.ApplicationConfig().DisableMetrics {
132133
metricsService, err := services.NewLocalAIMetricsService()
133134
if err != nil {
@@ -141,6 +142,7 @@ func API(application *application.Application) (*fiber.App, error) {
141142
})
142143
}
143144
}
145+
144146
// Health Checks should always be exempt from auth, so register these first
145147
routes.HealthRoutes(router)
146148

@@ -202,12 +204,28 @@ func API(application *application.Application) (*fiber.App, error) {
202204
routes.RegisterElevenLabsRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
203205
routes.RegisterLocalAIRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
204206
routes.RegisterOpenAIRoutes(router, requestExtractor, application)
207+
205208
if !application.ApplicationConfig().DisableWebUI {
209+
210+
// Create metrics store for tracking usage (before API routes registration)
211+
metricsStore := services.NewInMemoryMetricsStore()
212+
213+
// Add metrics middleware BEFORE API routes so it can intercept them
214+
router.Use(middleware.MetricsMiddleware(metricsStore))
215+
216+
// Register cleanup on shutdown
217+
router.Hooks().OnShutdown(func() error {
218+
metricsStore.Stop()
219+
log.Info().Msg("Metrics store stopped")
220+
return nil
221+
})
222+
206223
// Create opcache for tracking UI operations
207224
opcache := services.NewOpCache(application.GalleryService())
208-
routes.RegisterUIAPIRoutes(router, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), opcache)
225+
routes.RegisterUIAPIRoutes(router, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, metricsStore)
209226
routes.RegisterUIRoutes(router, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
210227
}
228+
211229
routes.RegisterJINARoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
212230

213231
// Define a custom 404 handler
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package localai
2+
3+
import (
4+
"github.com/gofiber/fiber/v2"
5+
"github.com/mudler/LocalAI/core/config"
6+
"github.com/mudler/LocalAI/core/gallery"
7+
"github.com/mudler/LocalAI/core/http/utils"
8+
"github.com/mudler/LocalAI/core/services"
9+
"github.com/mudler/LocalAI/internal"
10+
"github.com/mudler/LocalAI/pkg/model"
11+
)
12+
13+
// SettingsEndpoint handles the settings page which shows detailed model/backend management
14+
func SettingsEndpoint(appConfig *config.ApplicationConfig,
15+
cl *config.ModelConfigLoader, ml *model.ModelLoader, opcache *services.OpCache) func(*fiber.Ctx) error {
16+
return func(c *fiber.Ctx) error {
17+
modelConfigs := cl.GetAllModelsConfigs()
18+
galleryConfigs := map[string]*gallery.ModelConfig{}
19+
20+
installedBackends, err := gallery.ListSystemBackends(appConfig.SystemState)
21+
if err != nil {
22+
return err
23+
}
24+
25+
for _, m := range modelConfigs {
26+
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
27+
if err != nil {
28+
continue
29+
}
30+
galleryConfigs[m.Name] = cfg
31+
}
32+
33+
loadedModels := ml.ListLoadedModels()
34+
loadedModelsMap := map[string]bool{}
35+
for _, m := range loadedModels {
36+
loadedModelsMap[m.ID] = true
37+
}
38+
39+
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
40+
41+
// Get model statuses to display in the UI the operation in progress
42+
processingModels, taskTypes := opcache.GetStatus()
43+
44+
summary := fiber.Map{
45+
"Title": "LocalAI - Settings & Management",
46+
"Version": internal.PrintableVersion(),
47+
"BaseURL": utils.BaseURL(c),
48+
"Models": modelsWithoutConfig,
49+
"ModelsConfig": modelConfigs,
50+
"GalleryConfig": galleryConfigs,
51+
"ApplicationConfig": appConfig,
52+
"ProcessingModels": processingModels,
53+
"TaskTypes": taskTypes,
54+
"LoadedModels": loadedModelsMap,
55+
"InstalledBackends": installedBackends,
56+
}
57+
58+
// Render settings page
59+
return c.Render("views/settings", summary)
60+
}
61+
}

core/http/middleware/metrics.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package middleware
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"time"
7+
8+
"github.com/gofiber/fiber/v2"
9+
"github.com/mudler/LocalAI/core/services"
10+
"github.com/rs/zerolog/log"
11+
)
12+
13+
// MetricsMiddleware creates a middleware that tracks API usage metrics
14+
// Note: Uses CONTEXT_LOCALS_KEY_MODEL_NAME constant defined in request.go
15+
func MetricsMiddleware(metricsStore services.MetricsStore) fiber.Handler {
16+
return func(c *fiber.Ctx) error {
17+
path := c.Path()
18+
19+
// Skip tracking for UI routes, static files, and non-API endpoints
20+
if shouldSkipMetrics(path) {
21+
return c.Next()
22+
}
23+
24+
// Record start time
25+
start := time.Now()
26+
27+
// Get endpoint category
28+
endpoint := categorizeEndpoint(path)
29+
30+
// Continue with the request
31+
err := c.Next()
32+
33+
// Record metrics after request completes
34+
duration := time.Since(start)
35+
success := err == nil && c.Response().StatusCode() < 400
36+
37+
// Extract model name from context (set by RequestExtractor middleware)
38+
// Use the same constant as RequestExtractor
39+
model := "unknown"
40+
if modelVal, ok := c.Locals(CONTEXT_LOCALS_KEY_MODEL_NAME).(string); ok && modelVal != "" {
41+
model = modelVal
42+
log.Debug().Str("model", model).Str("endpoint", endpoint).Msg("Recording metrics for request")
43+
} else {
44+
// Fallback: try to extract from path params or query
45+
model = extractModelFromRequest(c)
46+
log.Debug().Str("model", model).Str("endpoint", endpoint).Msg("Recording metrics for request (fallback)")
47+
}
48+
49+
// Extract backend from response headers if available
50+
backend := string(c.Response().Header.Peek("X-LocalAI-Backend"))
51+
52+
// Record the request
53+
metricsStore.RecordRequest(endpoint, model, backend, success, duration)
54+
55+
return err
56+
}
57+
}
58+
59+
// shouldSkipMetrics determines if a request should be excluded from metrics
60+
func shouldSkipMetrics(path string) bool {
61+
// Skip UI routes
62+
skipPrefixes := []string{
63+
"/views/",
64+
"/static/",
65+
"/browse/",
66+
"/chat/",
67+
"/text2image/",
68+
"/tts/",
69+
"/talk/",
70+
"/models/edit/",
71+
"/import-model",
72+
"/settings",
73+
"/api/models", // UI API endpoints
74+
"/api/backends", // UI API endpoints
75+
"/api/operations", // UI API endpoints
76+
"/api/p2p", // UI API endpoints
77+
"/api/metrics", // Metrics API itself
78+
}
79+
80+
for _, prefix := range skipPrefixes {
81+
if strings.HasPrefix(path, prefix) {
82+
return true
83+
}
84+
}
85+
86+
// Also skip root path and other UI pages
87+
if path == "/" || path == "/index" {
88+
return true
89+
}
90+
91+
return false
92+
}
93+
94+
// categorizeEndpoint maps request paths to friendly endpoint categories
95+
func categorizeEndpoint(path string) string {
96+
// OpenAI-compatible endpoints
97+
if strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/chat/completions") {
98+
return "chat"
99+
}
100+
if strings.HasPrefix(path, "/v1/completions") || strings.HasPrefix(path, "/completions") {
101+
return "completions"
102+
}
103+
if strings.HasPrefix(path, "/v1/embeddings") || strings.HasPrefix(path, "/embeddings") {
104+
return "embeddings"
105+
}
106+
if strings.HasPrefix(path, "/v1/images/generations") || strings.HasPrefix(path, "/images/generations") {
107+
return "image-generation"
108+
}
109+
if strings.HasPrefix(path, "/v1/audio/transcriptions") || strings.HasPrefix(path, "/audio/transcriptions") {
110+
return "transcriptions"
111+
}
112+
if strings.HasPrefix(path, "/v1/audio/speech") || strings.HasPrefix(path, "/audio/speech") {
113+
return "text-to-speech"
114+
}
115+
if strings.HasPrefix(path, "/v1/models") || strings.HasPrefix(path, "/models") {
116+
return "models"
117+
}
118+
119+
// LocalAI-specific endpoints
120+
if strings.HasPrefix(path, "/v1/internal") {
121+
return "internal"
122+
}
123+
if strings.Contains(path, "/tts") {
124+
return "text-to-speech"
125+
}
126+
if strings.Contains(path, "/stt") || strings.Contains(path, "/whisper") {
127+
return "speech-to-text"
128+
}
129+
if strings.Contains(path, "/sound-generation") {
130+
return "sound-generation"
131+
}
132+
133+
// Default to the first path segment
134+
parts := strings.Split(strings.Trim(path, "/"), "/")
135+
if len(parts) > 0 {
136+
return parts[0]
137+
}
138+
139+
return "unknown"
140+
}
141+
142+
// extractModelFromRequest attempts to extract the model name from the request
143+
func extractModelFromRequest(c *fiber.Ctx) string {
144+
// Try query parameter first
145+
model := c.Query("model")
146+
if model != "" {
147+
return model
148+
}
149+
150+
// Try to extract from JSON body for POST requests
151+
if c.Method() == fiber.MethodPost {
152+
// Read body
153+
bodyBytes := c.Body()
154+
if len(bodyBytes) > 0 {
155+
// Parse JSON
156+
var reqBody map[string]interface{}
157+
if err := json.Unmarshal(bodyBytes, &reqBody); err == nil {
158+
if modelVal, ok := reqBody["model"]; ok {
159+
if modelStr, ok := modelVal.(string); ok {
160+
return modelStr
161+
}
162+
}
163+
}
164+
}
165+
}
166+
167+
// Try path parameter for endpoints like /models/:model
168+
model = c.Params("model")
169+
if model != "" {
170+
return model
171+
}
172+
173+
return "unknown"
174+
}

core/http/middleware/request.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
127127
log.Debug().Str("context localModelName", localModelName).Msg("overriding empty model name in request body with value found earlier in middleware chain")
128128
input.ModelName(&localModelName)
129129
}
130+
} else {
131+
// Update context locals with the model name from the request body
132+
// This ensures downstream middleware (like metrics) can access it
133+
ctx.Locals(CONTEXT_LOCALS_KEY_MODEL_NAME, input.ModelName(nil))
130134
}
131135

132136
cfg, err := re.modelConfigLoader.LoadModelConfigFileByNameDefaultOptions(input.ModelName(nil), re.applicationConfig)

core/http/routes/ui.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ func RegisterUIRoutes(app *fiber.App,
2323

2424
app.Get("/", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
2525

26+
// Settings page - detailed model/backend management
27+
app.Get("/settings", localai.SettingsEndpoint(appConfig, cl, ml, processingOps))
28+
2629
// P2P
2730
app.Get("/p2p", func(c *fiber.Ctx) error {
2831
summary := fiber.Map{

0 commit comments

Comments
 (0)