mirror of
https://github.com/mudler/LocalAI.git
synced 2026-03-31 13:15:51 -04:00
feat(ui): Per model backend logs and various fixes (#9028)
* feat(gallery): Switch to expandable box instead of pop-over and display model files Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(ui, backends): Add individual backend logging Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(ui): Set the context settings from the model config Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
committed by
GitHub
parent
eef808d921
commit
35d509d8e7
@@ -136,6 +136,8 @@ func New(opts ...config.AppOption) (*Application, error) {
|
||||
loadRuntimeSettingsFromFile(options)
|
||||
}
|
||||
|
||||
application.ModelLoader().SetBackendLoggingEnabled(options.EnableBackendLogging)
|
||||
|
||||
// turn off any process that was started by GRPC if the context is canceled
|
||||
go func() {
|
||||
<-options.Context.Done()
|
||||
@@ -382,6 +384,12 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
if settings.EnableBackendLogging != nil {
|
||||
if !options.EnableBackendLogging {
|
||||
options.EnableBackendLogging = *settings.EnableBackendLogging
|
||||
}
|
||||
}
|
||||
|
||||
xlog.Debug("Runtime settings loaded from runtime_settings.json")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ package backend
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/trace"
|
||||
"github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
)
|
||||
@@ -18,6 +20,7 @@ func Detection(
|
||||
opts := ModelOptions(modelConfig, appConfig)
|
||||
detectionModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,9 +28,35 @@ func Detection(
|
||||
return nil, fmt.Errorf("could not load detection model")
|
||||
}
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
res, err := detectionModel.Detect(context.Background(), &proto.DetectOptions{
|
||||
Src: sourceFile,
|
||||
})
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
errStr := ""
|
||||
if err != nil {
|
||||
errStr = err.Error()
|
||||
}
|
||||
|
||||
trace.RecordBackendTrace(trace.BackendTrace{
|
||||
Timestamp: startTime,
|
||||
Duration: time.Since(startTime),
|
||||
Type: trace.BackendTraceDetection,
|
||||
ModelName: modelConfig.Name,
|
||||
Backend: modelConfig.Backend,
|
||||
Summary: trace.TruncateString(sourceFile, 200),
|
||||
Error: errStr,
|
||||
Data: map[string]any{
|
||||
"source_file": sourceFile,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ func ModelEmbedding(s string, tokens []int, loader *model.ModelLoader, modelConf
|
||||
|
||||
inferenceModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ func ImageGeneration(height, width, step, seed int, positive_prompt, negative_pr
|
||||
opts...,
|
||||
)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ func ModelInference(ctx context.Context, s string, messages schema.Messages, ima
|
||||
opts := ModelOptions(*c, o)
|
||||
inferenceModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(o, c.Name, c.Backend, err, map[string]any{"model_file": modelFile})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/trace"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// recordModelLoadFailure records a backend trace when model loading fails.
|
||||
func recordModelLoadFailure(appConfig *config.ApplicationConfig, modelName, backend string, err error, data map[string]any) {
|
||||
if !appConfig.EnableTracing {
|
||||
return
|
||||
}
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.RecordBackendTrace(trace.BackendTrace{
|
||||
Timestamp: time.Now(),
|
||||
Type: trace.BackendTraceModelLoad,
|
||||
ModelName: modelName,
|
||||
Backend: backend,
|
||||
Summary: "Model load failed",
|
||||
Error: err.Error(),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func ModelOptions(c config.ModelConfig, so *config.ApplicationConfig, opts ...model.Option) []model.Option {
|
||||
name := c.Name
|
||||
if name == "" {
|
||||
|
||||
@@ -15,6 +15,7 @@ func Rerank(request *proto.RerankRequest, loader *model.ModelLoader, appConfig *
|
||||
opts := ModelOptions(modelConfig, appConfig)
|
||||
rerankModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ func SoundGeneration(
|
||||
opts := ModelOptions(modelConfig, appConfig)
|
||||
soundGenModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ func TokenMetrics(
|
||||
opts := ModelOptions(modelConfig, appConfig, model.WithModel(modelFile))
|
||||
model, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ func ModelTokenize(s string, loader *model.ModelLoader, modelConfig config.Model
|
||||
opts := ModelOptions(modelConfig, appConfig)
|
||||
inferenceModel, err = loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return schema.TokenizeResponse{}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ func ModelTranscription(audio, language string, translate, diarize bool, prompt
|
||||
|
||||
transcriptionModel, err := ml.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ func ModelTTS(
|
||||
opts := ModelOptions(modelConfig, appConfig)
|
||||
ttsModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
@@ -131,6 +132,7 @@ func ModelTTSStream(
|
||||
opts := ModelOptions(modelConfig, appConfig)
|
||||
ttsModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ func VAD(request *schema.VADRequest,
|
||||
opts := ModelOptions(modelConfig, appConfig)
|
||||
vadModel, err := ml.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ func VideoGeneration(height, width int32, prompt, negativePrompt, startImage, en
|
||||
opts...,
|
||||
)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type ApplicationConfig struct {
|
||||
Debug bool
|
||||
EnableTracing bool
|
||||
TracingMaxItems int
|
||||
EnableBackendLogging bool
|
||||
GeneratedContentDir string
|
||||
|
||||
UploadDir string
|
||||
@@ -213,6 +214,10 @@ var EnableTracing = func(o *ApplicationConfig) {
|
||||
o.EnableTracing = true
|
||||
}
|
||||
|
||||
var EnableBackendLogging = func(o *ApplicationConfig) {
|
||||
o.EnableBackendLogging = true
|
||||
}
|
||||
|
||||
var EnableWatchDogIdleCheck = func(o *ApplicationConfig) {
|
||||
o.WatchDog = true
|
||||
o.WatchDogIdle = true
|
||||
@@ -743,6 +748,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
debug := o.Debug
|
||||
tracingMaxItems := o.TracingMaxItems
|
||||
enableTracing := o.EnableTracing
|
||||
enableBackendLogging := o.EnableBackendLogging
|
||||
cors := o.CORS
|
||||
csrf := o.CSRF
|
||||
corsAllowOrigins := o.CORSAllowOrigins
|
||||
@@ -816,6 +822,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
Debug: &debug,
|
||||
TracingMaxItems: &tracingMaxItems,
|
||||
EnableTracing: &enableTracing,
|
||||
EnableBackendLogging: &enableBackendLogging,
|
||||
CORS: &cors,
|
||||
CSRF: &csrf,
|
||||
CORSAllowOrigins: &corsAllowOrigins,
|
||||
@@ -944,6 +951,9 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
|
||||
if settings.TracingMaxItems != nil {
|
||||
o.TracingMaxItems = *settings.TracingMaxItems
|
||||
}
|
||||
if settings.EnableBackendLogging != nil {
|
||||
o.EnableBackendLogging = *settings.EnableBackendLogging
|
||||
}
|
||||
if settings.CORS != nil {
|
||||
o.CORS = *settings.CORS
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ type RuntimeSettings struct {
|
||||
ContextSize *int `json:"context_size,omitempty"`
|
||||
F16 *bool `json:"f16,omitempty"`
|
||||
Debug *bool `json:"debug,omitempty"`
|
||||
EnableTracing *bool `json:"enable_tracing,omitempty"`
|
||||
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
|
||||
EnableTracing *bool `json:"enable_tracing,omitempty"`
|
||||
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
|
||||
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
|
||||
|
||||
// Security/CORS settings
|
||||
CORS *bool `json:"cors,omitempty"`
|
||||
|
||||
@@ -136,6 +136,12 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
appConfig.ApiKeys = append(envKeys, runtimeKeys...)
|
||||
}
|
||||
|
||||
// Update backend logging dynamically
|
||||
if settings.EnableBackendLogging != nil {
|
||||
app.ModelLoader().SetBackendLoggingEnabled(*settings.EnableBackendLogging)
|
||||
xlog.Info("Updated backend logging setting", "enableBackendLogging", *settings.EnableBackendLogging)
|
||||
}
|
||||
|
||||
// Update watchdog dynamically for settings that don't require restart
|
||||
if settings.ForceEvictionWhenBusy != nil {
|
||||
currentWD := app.ModelLoader().GetWatchDog()
|
||||
|
||||
@@ -29,8 +29,10 @@ type APIExchangeResponse struct {
|
||||
|
||||
type APIExchange struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Request APIExchangeRequest `json:"request"`
|
||||
Response APIExchangeResponse `json:"response"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var traceBuffer *circularbuffer.Queue[APIExchange]
|
||||
@@ -108,13 +110,18 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||
}
|
||||
c.Response().Writer = mw
|
||||
|
||||
err = next(c)
|
||||
if err != nil {
|
||||
c.Response().Writer = mw.ResponseWriter // Restore original writer if error
|
||||
return err
|
||||
handlerErr := next(c)
|
||||
|
||||
// Restore original writer unconditionally
|
||||
c.Response().Writer = mw.ResponseWriter
|
||||
|
||||
// Determine response status (use 500 if handler errored and no status was set)
|
||||
status := c.Response().Status
|
||||
if status == 0 && handlerErr != nil {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// Create exchange log
|
||||
// Create exchange log (always, even on error)
|
||||
requestHeaders := c.Request().Header.Clone()
|
||||
requestBody := make([]byte, len(body))
|
||||
copy(requestBody, body)
|
||||
@@ -123,6 +130,7 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||
copy(responseBody, resBody.Bytes())
|
||||
exchange := APIExchange{
|
||||
Timestamp: startTime,
|
||||
Duration: time.Since(startTime),
|
||||
Request: APIExchangeRequest{
|
||||
Method: c.Request().Method,
|
||||
Path: c.Path(),
|
||||
@@ -130,11 +138,14 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||
Body: &requestBody,
|
||||
},
|
||||
Response: APIExchangeResponse{
|
||||
Status: c.Response().Status,
|
||||
Status: status,
|
||||
Headers: &responseHeaders,
|
||||
Body: &responseBody,
|
||||
},
|
||||
}
|
||||
if handlerErr != nil {
|
||||
exchange.Error = handlerErr.Error()
|
||||
}
|
||||
|
||||
select {
|
||||
case logChan <- exchange:
|
||||
@@ -142,7 +153,7 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||
xlog.Warn("Trace channel full, dropping trace")
|
||||
}
|
||||
|
||||
return nil
|
||||
return handlerErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
64
core/http/react-ui/e2e/backend-logs.spec.js
Normal file
64
core/http/react-ui/e2e/backend-logs.spec.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Backend Logs', () => {
|
||||
test('model detail page shows title', async ({ page }) => {
|
||||
await page.goto('/app/backend-logs/mock-model')
|
||||
await expect(page.locator('.page-title')).toContainText('mock-model')
|
||||
})
|
||||
|
||||
test('no back arrow link on detail page', async ({ page }) => {
|
||||
await page.goto('/app/backend-logs/mock-model')
|
||||
await expect(page.locator('a[href="/app/backend-logs"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('filter buttons are visible', async ({ page }) => {
|
||||
await page.goto('/app/backend-logs/mock-model')
|
||||
await expect(page.locator('button', { hasText: 'All' })).toBeVisible()
|
||||
await expect(page.locator('button', { hasText: 'stdout' })).toBeVisible()
|
||||
await expect(page.locator('button', { hasText: 'stderr' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('filter buttons toggle active state', async ({ page }) => {
|
||||
await page.goto('/app/backend-logs/mock-model')
|
||||
|
||||
const allBtn = page.locator('button', { hasText: 'All' })
|
||||
const stdoutBtn = page.locator('button', { hasText: 'stdout' })
|
||||
|
||||
// All is active by default
|
||||
await expect(allBtn).toHaveClass(/btn-primary/)
|
||||
|
||||
// Click stdout
|
||||
await stdoutBtn.click()
|
||||
await expect(stdoutBtn).toHaveClass(/btn-primary/)
|
||||
await expect(allBtn).not.toHaveClass(/btn-primary/)
|
||||
})
|
||||
|
||||
test('export button is present', async ({ page }) => {
|
||||
await page.goto('/app/backend-logs/mock-model')
|
||||
await expect(page.locator('button', { hasText: 'Export' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('auto-scroll checkbox is present', async ({ page }) => {
|
||||
await page.goto('/app/backend-logs/mock-model')
|
||||
await expect(page.locator('text=Auto-scroll')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clear button is present', async ({ page }) => {
|
||||
await page.goto('/app/backend-logs/mock-model')
|
||||
await expect(page.locator('button', { hasText: 'Clear' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('details toggle button is present and toggles', async ({ page }) => {
|
||||
await page.goto('/app/backend-logs/mock-model')
|
||||
|
||||
// "Text only" button visible by default (details are shown)
|
||||
const toggleBtn = page.locator('button', { hasText: 'Text only' })
|
||||
await expect(toggleBtn).toBeVisible()
|
||||
|
||||
// Click to hide details
|
||||
await toggleBtn.click()
|
||||
|
||||
// Button label changes to "Show details"
|
||||
await expect(page.locator('button', { hasText: 'Show details' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
29
core/http/react-ui/e2e/manage-logs-link.spec.js
Normal file
29
core/http/react-ui/e2e/manage-logs-link.spec.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Manage Page - Backend Logs Link', () => {
|
||||
test('models table shows terminal icon for logs', async ({ page }) => {
|
||||
await page.goto('/app/manage')
|
||||
// Wait for models to load
|
||||
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Check for terminal icon (backend logs link)
|
||||
const terminalIcon = page.locator('a[title="Backend logs"] i.fa-terminal')
|
||||
await expect(terminalIcon.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('terminal icon links to backend-logs page', async ({ page }) => {
|
||||
await page.goto('/app/manage')
|
||||
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const logsLink = page.locator('a[title="Backend logs"]').first()
|
||||
await expect(logsLink).toBeVisible()
|
||||
|
||||
// Link uses href="#" with onClick for navigation
|
||||
const href = await logsLink.getAttribute('href')
|
||||
expect(href).toBe('#')
|
||||
|
||||
// Click and verify navigation
|
||||
await logsLink.click()
|
||||
await expect(page).toHaveURL(/\/app\/backend-logs\//)
|
||||
})
|
||||
})
|
||||
36
core/http/react-ui/e2e/settings-backend-logging.spec.js
Normal file
36
core/http/react-ui/e2e/settings-backend-logging.spec.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Settings - Backend Logging', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/app/settings')
|
||||
// Wait for settings to load
|
||||
await expect(page.locator('h3', { hasText: 'Tracing' })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('backend logging toggle is visible in tracing section', async ({ page }) => {
|
||||
await expect(page.locator('text=Enable Backend Logging')).toBeVisible()
|
||||
})
|
||||
|
||||
test('backend logging toggle can be toggled', async ({ page }) => {
|
||||
// Find the checkbox associated with backend logging
|
||||
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })
|
||||
const checkbox = section.locator('input[type="checkbox"]').last()
|
||||
|
||||
// Toggle on
|
||||
const wasChecked = await checkbox.isChecked()
|
||||
await checkbox.locator('..').click()
|
||||
if (wasChecked) {
|
||||
await expect(checkbox).not.toBeChecked()
|
||||
} else {
|
||||
await expect(checkbox).toBeChecked()
|
||||
}
|
||||
})
|
||||
|
||||
test('save shows toast', async ({ page }) => {
|
||||
// Click save button
|
||||
await page.locator('button', { hasText: 'Save' }).click()
|
||||
|
||||
// Verify toast appears
|
||||
await expect(page.locator('text=Settings saved')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
50
core/http/react-ui/e2e/traces-errors.spec.js
Normal file
50
core/http/react-ui/e2e/traces-errors.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Traces - Error Display', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock API traces with sample data so the table renders
|
||||
await page.route('**/api/traces', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
request: { method: 'POST', path: '/v1/chat/completions' },
|
||||
response: { status: 200 },
|
||||
error: null,
|
||||
},
|
||||
]),
|
||||
})
|
||||
})
|
||||
// Mock backend traces with sample data
|
||||
await page.route('**/api/backend-traces', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
type: 'model_load',
|
||||
timestamp: Date.now() * 1_000_000,
|
||||
model_name: 'mock-model',
|
||||
summary: 'Loaded model',
|
||||
duration: 500_000_000,
|
||||
error: null,
|
||||
},
|
||||
]),
|
||||
})
|
||||
})
|
||||
await page.goto('/app/traces')
|
||||
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('API traces tab has Result column header', async ({ page }) => {
|
||||
// API tab is active by default
|
||||
await expect(page.locator('th', { hasText: 'Result' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('backend traces tab shows model_load type if present', async ({ page }) => {
|
||||
// Switch to backend traces tab
|
||||
await page.locator('button', { hasText: 'Backend Traces' }).click()
|
||||
|
||||
// The table should be visible with Type column
|
||||
await expect(page.locator('th', { hasText: 'Type' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -2109,7 +2109,7 @@
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--color-bg-tertiary);
|
||||
background: var(--color-border-default);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
297
core/http/react-ui/src/pages/BackendLogs.jsx
Normal file
297
core/http/react-ui/src/pages/BackendLogs.jsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { useParams, useSearchParams, useOutletContext, Link } from 'react-router-dom'
|
||||
import { backendLogsApi } from '../utils/api'
|
||||
import { formatTimestamp } from '../utils/format'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
|
||||
function wsUrl(path) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}${apiUrl(path)}`
|
||||
}
|
||||
|
||||
const STREAM_BADGE = {
|
||||
stdout: { bg: 'rgba(59,130,246,0.15)', color: '#60a5fa', label: 'stdout' },
|
||||
stderr: { bg: 'rgba(239,68,68,0.15)', color: '#f87171', label: 'stderr' },
|
||||
}
|
||||
|
||||
// Detail view: log lines for a specific model
|
||||
function BackendLogsDetail({ modelId }) {
|
||||
const { addToast } = useOutletContext()
|
||||
const [searchParams] = useSearchParams()
|
||||
const fromTimestamp = searchParams.get('from')
|
||||
|
||||
const [lines, setLines] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [showDetails, setShowDetails] = useState(true)
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
const logContainerRef = useRef(null)
|
||||
const wsRef = useRef(null)
|
||||
const reconnectTimerRef = useRef(null)
|
||||
const loadingRef = useRef(true)
|
||||
const scrolledToTimestampRef = useRef(false)
|
||||
const pendingLinesRef = useRef([])
|
||||
const flushTimerRef = useRef(null)
|
||||
|
||||
// Keep loadingRef in sync
|
||||
useEffect(() => { loadingRef.current = loading }, [loading])
|
||||
|
||||
// Auto-scroll to bottom when new lines arrive
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [lines, autoScroll])
|
||||
|
||||
// WebSocket connection with reconnect
|
||||
const connectWebSocket = useCallback(() => {
|
||||
if (wsRef.current && wsRef.current.readyState <= 1) return
|
||||
|
||||
const url = wsUrl(`/ws/backend-logs/${encodeURIComponent(modelId)}`)
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setWsConnected(true)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'initial') {
|
||||
setLines(Array.isArray(msg.lines) ? msg.lines : [])
|
||||
setLoading(false)
|
||||
} else if (msg.type === 'line' && msg.line) {
|
||||
// Batch incoming lines to reduce renders
|
||||
pendingLinesRef.current.push(msg.line)
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = requestAnimationFrame(() => {
|
||||
const batch = pendingLinesRef.current
|
||||
pendingLinesRef.current = []
|
||||
flushTimerRef.current = null
|
||||
setLines(prev => prev.concat(batch))
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setWsConnected(false)
|
||||
reconnectTimerRef.current = setTimeout(connectWebSocket, 3000)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// Fall back to REST if WebSocket fails on first connect
|
||||
if (loadingRef.current) {
|
||||
backendLogsApi.getLines(modelId)
|
||||
.then(data => setLines(Array.isArray(data) ? data : []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}
|
||||
}, [modelId])
|
||||
|
||||
useEffect(() => {
|
||||
connectWebSocket()
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close()
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
|
||||
if (flushTimerRef.current) cancelAnimationFrame(flushTimerRef.current)
|
||||
}
|
||||
}, [connectWebSocket])
|
||||
|
||||
// Scroll to timestamp if `from` query param is set (once)
|
||||
useEffect(() => {
|
||||
if (!fromTimestamp || scrolledToTimestampRef.current || !logContainerRef.current || lines.length === 0) return
|
||||
const fromDate = new Date(fromTimestamp).getTime()
|
||||
const lineElements = logContainerRef.current.querySelectorAll('[data-log-line]')
|
||||
for (const el of lineElements) {
|
||||
const lineTime = new Date(el.dataset.timestamp).getTime()
|
||||
if (lineTime >= fromDate) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
el.style.background = 'rgba(59,130,246,0.1)'
|
||||
setTimeout(() => { el.style.background = '' }, 3000)
|
||||
scrolledToTimestampRef.current = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [fromTimestamp, lines])
|
||||
|
||||
const filteredLines = useMemo(
|
||||
() => filter === 'all' ? lines : lines.filter(l => l.stream === filter),
|
||||
[lines, filter]
|
||||
)
|
||||
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await backendLogsApi.clear(modelId)
|
||||
setLines([])
|
||||
addToast('Logs cleared', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Failed to clear: ${err.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const blob = new Blob([JSON.stringify(filteredLines, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `backend-logs-${modelId}-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
|
||||
{modelId}
|
||||
</h1>
|
||||
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>Backend process output</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
{['all', 'stdout', 'stderr'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
className={`btn btn-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{f === 'all' ? 'All' : f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-danger btn-sm" onClick={handleClear}><i className="fas fa-trash" /> Clear</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleExport} disabled={filteredLines.length === 0}>
|
||||
<i className="fas fa-download" /> Export
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${showDetails ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={() => setShowDetails(prev => !prev)}
|
||||
title={showDetails ? 'Hide timestamps and stream labels for easier copying' : 'Show timestamps and stream labels'}
|
||||
>
|
||||
<i className={`fas ${showDetails ? 'fa-eye-slash' : 'fa-eye'}`} /> {showDetails ? 'Text only' : 'Show details'}
|
||||
</button>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', fontSize: '0.8125rem' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 8, height: 8,
|
||||
borderRadius: '50%',
|
||||
background: wsConnected ? 'var(--color-success)' : 'var(--color-text-muted)',
|
||||
}} />
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{wsConnected ? 'Live' : 'Reconnecting...'}
|
||||
</span>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', marginLeft: 'var(--spacing-sm)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
/>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>Auto-scroll</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log output */}
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
) : filteredLines.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-terminal" /></div>
|
||||
<h2 className="empty-state-title">No log lines</h2>
|
||||
<p className="empty-state-text">
|
||||
{filter !== 'all'
|
||||
? `No ${filter} output. Try switching to "All".`
|
||||
: 'Log output will appear here as the backend process runs.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
style={{
|
||||
background: 'var(--color-bg-primary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'auto',
|
||||
maxHeight: 'calc(100vh - 280px)',
|
||||
fontFamily: 'JetBrains Mono, Consolas, monospace',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{filteredLines.map((line, i) => {
|
||||
const badge = STREAM_BADGE[line.stream] || STREAM_BADGE.stdout
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-log-line
|
||||
data-timestamp={line.timestamp}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: showDetails ? 'var(--spacing-sm)' : undefined,
|
||||
padding: '2px var(--spacing-sm)',
|
||||
borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.03))',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{showDetails && (<>
|
||||
<span style={{ color: 'var(--color-text-muted)', flexShrink: 0, minWidth: 90 }}>
|
||||
{formatTimestamp(line.timestamp)}
|
||||
</span>
|
||||
<span style={{
|
||||
background: badge.bg, color: badge.color,
|
||||
padding: '0 4px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '0.625rem', fontWeight: 500, flexShrink: 0,
|
||||
lineHeight: '1.5',
|
||||
}}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</>)}
|
||||
<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', flex: 1 }}>
|
||||
{line.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BackendLogs() {
|
||||
const { modelId } = useParams()
|
||||
|
||||
if (modelId) {
|
||||
return <BackendLogsDetail modelId={decodeURIComponent(modelId)} />
|
||||
}
|
||||
|
||||
// No model specified — redirect to System page
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-terminal" /></div>
|
||||
<h2 className="empty-state-title">No model selected</h2>
|
||||
<p className="empty-state-text">
|
||||
View backend logs for a specific model from the{' '}
|
||||
<Link to="/app/manage" style={{ color: 'var(--color-primary)' }}>System page</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { backendsApi } from '../utils/api'
|
||||
import React from 'react'
|
||||
import { useOperations } from '../hooks/useOperations'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
export default function Backends() {
|
||||
const { addToast } = useOutletContext()
|
||||
@@ -21,7 +21,7 @@ export default function Backends() {
|
||||
const [manualUri, setManualUri] = useState('')
|
||||
const [manualName, setManualName] = useState('')
|
||||
const [manualAlias, setManualAlias] = useState('')
|
||||
const [selectedBackend, setSelectedBackend] = useState(null)
|
||||
const [expandedRow, setExpandedRow] = useState(null)
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
const [allBackends, setAllBackends] = useState([])
|
||||
@@ -246,6 +246,7 @@ export default function Backends() {
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 30 }}></th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<SortHeader col="name">Backend</SortHeader>
|
||||
<th>Description</th>
|
||||
@@ -256,12 +257,21 @@ export default function Backends() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backends.map(b => {
|
||||
{backends.map((b, idx) => {
|
||||
const op = getBackendOp(b)
|
||||
const isProcessing = !!op
|
||||
const isExpanded = expandedRow === idx
|
||||
|
||||
return (
|
||||
<tr key={b.name || b.id}>
|
||||
<React.Fragment key={b.name || b.id}>
|
||||
<tr
|
||||
onClick={() => setExpandedRow(isExpanded ? null : idx)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* Chevron */}
|
||||
<td style={{ width: 30 }}>
|
||||
<i className={`fas fa-chevron-${isExpanded ? 'down' : 'right'}`} style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', transition: 'transform 150ms' }} />
|
||||
</td>
|
||||
{/* Icon */}
|
||||
<td>
|
||||
{b.icon ? (
|
||||
@@ -279,12 +289,7 @@ export default function Backends() {
|
||||
|
||||
{/* Name */}
|
||||
<td>
|
||||
<span
|
||||
style={{ fontWeight: 500, cursor: 'pointer', color: 'var(--color-primary)' }}
|
||||
onClick={() => setSelectedBackend(b)}
|
||||
>
|
||||
{b.name || b.id}
|
||||
</span>
|
||||
<span style={{ fontWeight: 500 }}>{b.name || b.id}</span>
|
||||
</td>
|
||||
|
||||
{/* Description */}
|
||||
@@ -343,10 +348,7 @@ export default function Backends() {
|
||||
|
||||
{/* Actions */}
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(b)} title="Details">
|
||||
<i className="fas fa-info-circle" />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
|
||||
{b.installed ? (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Reinstall" disabled={isProcessing}>
|
||||
@@ -364,6 +366,15 @@ export default function Backends() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Expanded detail row */}
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan="8" style={{ padding: 0 }}>
|
||||
<BackendDetail backend={b} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
@@ -389,106 +400,67 @@ export default function Backends() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedBackend && (
|
||||
<Modal onClose={() => setSelectedBackend(null)}>
|
||||
<div style={{ padding: 'var(--spacing-md)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 'var(--spacing-md)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
|
||||
{selectedBackend.icon ? (
|
||||
<img src={selectedBackend.icon} alt="" style={{ width: 48, height: 48, borderRadius: 'var(--radius-md)' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-bg-tertiary)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<i className="fas fa-cog" style={{ fontSize: '1.25rem', color: 'var(--color-text-muted)' }} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 600, fontSize: '1.125rem' }}>{selectedBackend.name || selectedBackend.id}</h3>
|
||||
{selectedBackend.installed && <span className="badge badge-success">Installed</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(null)}>
|
||||
<i className="fas fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{selectedBackend.description && (
|
||||
<div style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<div
|
||||
style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', lineHeight: 1.6 }}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(selectedBackend.description) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{selectedBackend.tags && selectedBackend.tags.length > 0 && (
|
||||
<div style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<span className="form-label">Tags</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{selectedBackend.tags.map(tag => (
|
||||
<span key={tag} className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URLs */}
|
||||
{selectedBackend.urls && selectedBackend.urls.length > 0 && (
|
||||
<div style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<span className="form-label">Links</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{selectedBackend.urls.map((url, i) => (
|
||||
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', wordBreak: 'break-all' }}>
|
||||
<i className="fas fa-external-link-alt" style={{ marginRight: 4 }} />{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repository / License */}
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
|
||||
{selectedBackend.gallery && (
|
||||
<div>
|
||||
<span className="form-label">Repository</span>
|
||||
<p style={{ fontSize: '0.8125rem' }}>{typeof selectedBackend.gallery === 'string' ? selectedBackend.gallery : selectedBackend.gallery.name || '-'}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedBackend.license && (
|
||||
<div>
|
||||
<span className="form-label">License</span>
|
||||
<p style={{ fontSize: '0.8125rem' }}>{selectedBackend.license}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', borderTop: '1px solid var(--color-border-subtle)', paddingTop: 'var(--spacing-md)' }}>
|
||||
{selectedBackend.installed ? (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { handleInstall(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
|
||||
<i className="fas fa-rotate" /> Reinstall
|
||||
</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => { handleDelete(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
|
||||
<i className="fas fa-trash" /> Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { handleInstall(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
|
||||
<i className="fas fa-download" /> Install
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(null)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BackendDetailRow({ label, children }) {
|
||||
if (!children) return null
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ fontWeight: 500, fontSize: '0.8125rem', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap', verticalAlign: 'top', padding: '6px 12px 6px 0' }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8125rem', padding: '6px 0' }}>{children}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function BackendDetail({ backend }) {
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-md) var(--spacing-lg)', background: 'var(--color-bg-primary)', borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<BackendDetailRow label="Description">
|
||||
{backend.description && (
|
||||
<div
|
||||
style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(backend.description) }}
|
||||
/>
|
||||
)}
|
||||
</BackendDetailRow>
|
||||
<BackendDetailRow label="Repository">
|
||||
{backend.gallery && (
|
||||
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
|
||||
{typeof backend.gallery === 'string' ? backend.gallery : backend.gallery.name || '-'}
|
||||
</span>
|
||||
)}
|
||||
</BackendDetailRow>
|
||||
<BackendDetailRow label="License">
|
||||
{backend.license && <span>{backend.license}</span>}
|
||||
</BackendDetailRow>
|
||||
<BackendDetailRow label="Tags">
|
||||
{backend.tags?.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{backend.tags.map(tag => (
|
||||
<span key={tag} className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</BackendDetailRow>
|
||||
<BackendDetailRow label="Links">
|
||||
{backend.urls?.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{backend.urls.map((url, i) => (
|
||||
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', wordBreak: 'break-all' }}>
|
||||
<i className="fas fa-external-link-alt" style={{ marginRight: 4, fontSize: '0.6875rem' }} />{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</BackendDetailRow>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -354,6 +354,9 @@ export default function Chat() {
|
||||
modelsApi.getConfigJson(model).then(cfg => {
|
||||
if (cancelled) return
|
||||
setModelInfo(cfg)
|
||||
if (cfg?.context_size > 0 && activeChat) {
|
||||
updateChatSettings(activeChat.id, { contextSize: cfg.context_size })
|
||||
}
|
||||
const hasMcp = !!(cfg?.mcp?.remote || cfg?.mcp?.stdio)
|
||||
setMcpAvailable(hasMcp)
|
||||
if (!hasMcp && activeChat?.mcpMode) {
|
||||
|
||||
@@ -204,6 +204,14 @@ export default function Manage() {
|
||||
>
|
||||
<i className="fas fa-pen-to-square" />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) }}
|
||||
style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}
|
||||
title="Backend logs"
|
||||
>
|
||||
<i className="fas fa-terminal" />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import { useOperations } from '../hooks/useOperations'
|
||||
import { useResources } from '../hooks/useResources'
|
||||
import { formatBytes } from '../utils/format'
|
||||
import Modal from '../components/Modal'
|
||||
import React from 'react'
|
||||
|
||||
|
||||
const LOADING_PHRASES = [
|
||||
@@ -135,7 +134,8 @@ export default function Models() {
|
||||
const [sort, setSort] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const [installing, setInstalling] = useState(new Set())
|
||||
const [selectedModel, setSelectedModel] = useState(null)
|
||||
const [expandedRow, setExpandedRow] = useState(null)
|
||||
const [expandedFiles, setExpandedFiles] = useState(false)
|
||||
const [stats, setStats] = useState({ total: 0, installed: 0, repositories: 0 })
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
@@ -322,6 +322,7 @@ export default function Models() {
|
||||
<table className="table" style={{ minWidth: '800px' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th style={{ width: '60px' }}></th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('name')}>
|
||||
Model Name {sort === 'name' && <i className={`fas fa-arrow-${order === 'asc' ? 'up' : 'down'}`} style={{ fontSize: '0.625rem' }} />}
|
||||
@@ -335,14 +336,23 @@ export default function Models() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map(model => {
|
||||
{models.map((model, idx) => {
|
||||
const name = model.name || model.id
|
||||
const installing = isInstalling(name)
|
||||
const progress = getOperationProgress(name)
|
||||
const fit = fitsGpu(model.estimated_vram_bytes)
|
||||
const isExpanded = expandedRow === idx
|
||||
|
||||
return (
|
||||
<tr key={name}>
|
||||
<React.Fragment key={name}>
|
||||
<tr
|
||||
onClick={() => { setExpandedRow(isExpanded ? null : idx); setExpandedFiles(false) }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* Chevron */}
|
||||
<td style={{ width: 30 }}>
|
||||
<i className={`fas fa-chevron-${isExpanded ? 'down' : 'right'}`} style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', transition: 'transform 150ms' }} />
|
||||
</td>
|
||||
{/* Icon */}
|
||||
<td>
|
||||
<div style={{
|
||||
@@ -440,14 +450,7 @@ export default function Models() {
|
||||
|
||||
{/* Actions */}
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setSelectedModel(model)}
|
||||
title="Details"
|
||||
>
|
||||
<i className="fas fa-info-circle" />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
|
||||
{model.installed ? (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(name)} title="Reinstall">
|
||||
@@ -470,6 +473,15 @@ export default function Models() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Expanded detail row */}
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan="7" style={{ padding: 0 }}>
|
||||
<ModelDetail model={model} fit={fit} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
@@ -493,74 +505,125 @@ export default function Models() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedModel && (
|
||||
<Modal onClose={() => setSelectedModel(null)}>
|
||||
{/* Modal header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: 'var(--spacing-md)', borderBottom: '1px solid var(--color-border-subtle)',
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 600 }}>{selectedModel.name}</h3>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedModel(null)}>
|
||||
<i className="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Modal body */}
|
||||
<div style={{ padding: 'var(--spacing-md)', overflowY: 'auto', flex: 1 }}>
|
||||
{/* Icon */}
|
||||
{selectedModel.icon && (
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border-subtle)', overflow: 'hidden',
|
||||
marginBottom: 'var(--spacing-md)',
|
||||
}}>
|
||||
<img src={selectedModel.icon} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }) {
|
||||
if (!children) return null
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ fontWeight: 500, fontSize: '0.8125rem', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap', verticalAlign: 'top', padding: '6px 12px 6px 0' }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8125rem', padding: '6px 0' }}>{children}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelDetail({ model, fit, expandedFiles, setExpandedFiles }) {
|
||||
const files = model.additionalFiles || model.files || []
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-md) var(--spacing-lg)', background: 'var(--color-bg-primary)', borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<DetailRow label="Description">
|
||||
{model.description && (
|
||||
<span style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{model.description}</span>
|
||||
)}
|
||||
{/* Description */}
|
||||
{selectedModel.description && (
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', lineHeight: 1.6, marginBottom: 'var(--spacing-md)' }}>
|
||||
{selectedModel.description}
|
||||
</p>
|
||||
</DetailRow>
|
||||
<DetailRow label="Gallery">
|
||||
{model.gallery && (
|
||||
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
|
||||
{typeof model.gallery === 'string' ? model.gallery : model.gallery.name || '—'}
|
||||
</span>
|
||||
)}
|
||||
{/* Size/VRAM */}
|
||||
{(selectedModel.estimated_size_display || selectedModel.estimated_vram_display) && (
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>
|
||||
{selectedModel.estimated_size_display && <div>Size: {selectedModel.estimated_size_display}</div>}
|
||||
{selectedModel.estimated_vram_display && <div>VRAM: {selectedModel.estimated_vram_display}</div>}
|
||||
</div>
|
||||
)}
|
||||
{/* Tags */}
|
||||
{selectedModel.tags?.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginBottom: 'var(--spacing-md)' }}>
|
||||
{selectedModel.tags.map(tag => (
|
||||
<span key={tag} className="badge badge-info">{tag}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Size">
|
||||
{model.estimated_size_display && model.estimated_size_display !== '0 B' ? model.estimated_size_display : null}
|
||||
</DetailRow>
|
||||
<DetailRow label="VRAM">
|
||||
{model.estimated_vram_display && model.estimated_vram_display !== '0 B' ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{model.estimated_vram_display}
|
||||
{fit !== null && (
|
||||
<span style={{ fontSize: '0.75rem', color: fit ? 'var(--color-success)' : 'var(--color-error)' }}>
|
||||
<i className="fas fa-microchip" /> {fit ? 'Fits in GPU' : 'May not fit in GPU'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</DetailRow>
|
||||
<DetailRow label="License">
|
||||
{model.license && <span>{model.license}</span>}
|
||||
</DetailRow>
|
||||
<DetailRow label="Tags">
|
||||
{model.tags?.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{model.tags.map(tag => (
|
||||
<span key={tag} className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Links */}
|
||||
{selectedModel.urls?.length > 0 && (
|
||||
<div style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<h4 style={{ fontSize: '0.8125rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Links</h4>
|
||||
{selectedModel.urls.map((url, i) => (
|
||||
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ display: 'block', fontSize: '0.8125rem', color: 'var(--color-primary)', marginBottom: '2px' }}>
|
||||
{url}
|
||||
</DetailRow>
|
||||
<DetailRow label="Links">
|
||||
{model.urls?.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{model.urls.map((url, i) => (
|
||||
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', wordBreak: 'break-all' }}>
|
||||
<i className="fas fa-external-link-alt" style={{ marginRight: 4, fontSize: '0.6875rem' }} />{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Modal footer */}
|
||||
<div style={{
|
||||
padding: 'var(--spacing-sm) var(--spacing-md)',
|
||||
borderTop: '1px solid var(--color-border-subtle)',
|
||||
display: 'flex', justifyContent: 'flex-end',
|
||||
}}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedModel(null)}>Close</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</DetailRow>
|
||||
{model.trustRemoteCode && (
|
||||
<DetailRow label="Warning">
|
||||
<span className="badge badge-error" style={{ fontSize: '0.6875rem' }}>
|
||||
<i className="fas fa-circle-exclamation" /> Requires Trust Remote Code
|
||||
</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<DetailRow label="Files">
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); setExpandedFiles(!expandedFiles) }}
|
||||
style={{ marginBottom: expandedFiles ? 'var(--spacing-sm)' : 0 }}
|
||||
>
|
||||
<i className={`fas fa-chevron-${expandedFiles ? 'down' : 'right'}`} style={{ fontSize: '0.5rem', marginRight: 4 }} />
|
||||
{files.length} file{files.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
{expandedFiles && (
|
||||
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--color-bg-tertiary)' }}>
|
||||
<th style={{ padding: '4px 8px', textAlign: 'left', fontWeight: 500 }}>Filename</th>
|
||||
<th style={{ padding: '4px 8px', textAlign: 'left', fontWeight: 500 }}>URI</th>
|
||||
<th style={{ padding: '4px 8px', textAlign: 'left', fontWeight: 500 }}>SHA256</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((f, i) => (
|
||||
<tr key={i} style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
|
||||
<td style={{ padding: '4px 8px', fontFamily: 'monospace' }}>{f.filename || '—'}</td>
|
||||
<td style={{ padding: '4px 8px', wordBreak: 'break-all', maxWidth: 300 }}>{f.uri || '—'}</td>
|
||||
<td style={{ padding: '4px 8px', fontFamily: 'monospace', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
||||
{f.sha256 ? f.sha256.substring(0, 16) + '...' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -300,6 +300,9 @@ export default function Settings() {
|
||||
<SettingRow label="Max Items" description="Maximum number of trace items to retain (0 = unlimited)">
|
||||
<input className="input" type="number" style={{ width: 120 }} value={settings.tracing_max_items ?? ''} onChange={(e) => update('tracing_max_items', parseInt(e.target.value) || 0)} placeholder="100" disabled={!settings.enable_tracing} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Enable Backend Logging" description="Capture backend process output per model (without requiring debug mode)">
|
||||
<Toggle checked={settings.enable_backend_logging} onChange={(v) => update('enable_backend_logging', v)} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { tracesApi, settingsApi } from '../utils/api'
|
||||
import { formatTimestamp } from '../utils/format'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import Toggle from '../components/Toggle'
|
||||
import SettingRow from '../components/SettingRow'
|
||||
@@ -19,12 +20,6 @@ function formatDuration(ns) {
|
||||
return `${(ns / 1_000_000_000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '-'
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString() + '.' + String(d.getMilliseconds()).padStart(3, '0')
|
||||
}
|
||||
|
||||
function decodeTraceBody(body) {
|
||||
if (!body) return ''
|
||||
try {
|
||||
@@ -75,6 +70,8 @@ const TYPE_COLORS = {
|
||||
sound_generation: { bg: 'rgba(20,184,166,0.15)', color: '#2dd4bf' },
|
||||
rerank: { bg: 'rgba(99,102,241,0.15)', color: '#818cf8' },
|
||||
tokenize: { bg: 'rgba(107,114,128,0.15)', color: '#9ca3af' },
|
||||
detection: { bg: 'rgba(14,165,233,0.15)', color: '#38bdf8' },
|
||||
model_load: { bg: 'rgba(239,68,68,0.15)', color: '#f87171' },
|
||||
}
|
||||
|
||||
function typeBadgeStyle(type) {
|
||||
@@ -221,6 +218,18 @@ function BackendTraceDetail({ trace }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backend logs link */}
|
||||
{trace.model_name && (
|
||||
<div style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<a
|
||||
href={`/app/backend-logs/${encodeURIComponent(trace.model_name)}${trace.timestamp ? `?from=${encodeURIComponent(trace.timestamp)}` : ''}`}
|
||||
style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}
|
||||
>
|
||||
<i className="fas fa-terminal" /> View backend logs
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio snippet */}
|
||||
{trace.data && <AudioSnippet data={trace.data} />}
|
||||
|
||||
@@ -234,6 +243,16 @@ function BackendTraceDetail({ trace }) {
|
||||
function ApiTraceDetail({ trace }) {
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-md)', background: 'var(--color-bg-secondary)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{trace.error && (
|
||||
<div style={{
|
||||
background: 'var(--color-error-light)', border: '1px solid var(--color-error-border)',
|
||||
borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)',
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)',
|
||||
}}>
|
||||
<i className="fas fa-exclamation-triangle" style={{ color: 'var(--color-error)' }} />
|
||||
<span style={{ color: 'var(--color-error)', fontSize: '0.8125rem', fontFamily: 'monospace', wordBreak: 'break-all' }}>{trace.error}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-md)' }}>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '0.8125rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Request Body</h4>
|
||||
@@ -452,6 +471,7 @@ export default function Traces() {
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Status</th>
|
||||
<th style={{ width: '40px' }}>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -462,10 +482,15 @@ export default function Traces() {
|
||||
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
|
||||
<td style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '0.8125rem' }}>{trace.request?.path || '-'}</td>
|
||||
<td><span className={`badge ${(trace.response?.status || 0) < 400 ? 'badge-success' : 'badge-error'}`}>{trace.response?.status || '-'}</span></td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{trace.error
|
||||
? <i className="fas fa-times-circle" style={{ color: 'var(--color-error)' }} title={trace.error} />
|
||||
: <i className="fas fa-check-circle" style={{ color: 'var(--color-success)' }} />}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedRow === i && (
|
||||
<tr>
|
||||
<td colSpan="4" style={{ padding: 0 }}>
|
||||
<td colSpan="5" style={{ padding: 0 }}>
|
||||
<ApiTraceDetail trace={trace} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -27,6 +27,7 @@ import AgentTaskDetails from './pages/AgentTaskDetails'
|
||||
import AgentJobDetails from './pages/AgentJobDetails'
|
||||
import ModelEditor from './pages/ModelEditor'
|
||||
import ImportModel from './pages/ImportModel'
|
||||
import BackendLogs from './pages/BackendLogs'
|
||||
import Explorer from './pages/Explorer'
|
||||
import Login from './pages/Login'
|
||||
import NotFound from './pages/NotFound'
|
||||
@@ -54,6 +55,7 @@ const appChildren = [
|
||||
{ path: 'backends', element: <Backends /> },
|
||||
{ path: 'settings', element: <Settings /> },
|
||||
{ path: 'traces', element: <Traces /> },
|
||||
{ path: 'backend-logs/:modelId', element: <BackendLogs /> },
|
||||
{ path: 'p2p', element: <P2P /> },
|
||||
{ path: 'agents', element: <Agents /> },
|
||||
{ path: 'agents/new', element: <AgentCreate /> },
|
||||
|
||||
7
core/http/react-ui/src/utils/api.js
vendored
7
core/http/react-ui/src/utils/api.js
vendored
@@ -140,6 +140,13 @@ export const settingsApi = {
|
||||
save: (body) => postJSON(API_CONFIG.endpoints.settings, body),
|
||||
}
|
||||
|
||||
// Backend Logs API
|
||||
export const backendLogsApi = {
|
||||
listModels: () => fetchJSON(API_CONFIG.endpoints.backendLogs),
|
||||
getLines: (modelId) => fetchJSON(API_CONFIG.endpoints.backendLogsModel(modelId)),
|
||||
clear: (modelId) => postJSON(API_CONFIG.endpoints.clearBackendLogs(modelId), {}),
|
||||
}
|
||||
|
||||
// Traces API
|
||||
export const tracesApi = {
|
||||
get: () => fetchJSON(API_CONFIG.endpoints.traces),
|
||||
|
||||
5
core/http/react-ui/src/utils/config.js
vendored
5
core/http/react-ui/src/utils/config.js
vendored
@@ -33,6 +33,11 @@ export const API_CONFIG = {
|
||||
backendTraces: '/api/backend-traces',
|
||||
clearBackendTraces: '/api/backend-traces/clear',
|
||||
|
||||
// Backend Logs
|
||||
backendLogs: '/api/backend-logs',
|
||||
backendLogsModel: (modelId) => `/api/backend-logs/${encodeURIComponent(modelId)}`,
|
||||
clearBackendLogs: (modelId) => `/api/backend-logs/${encodeURIComponent(modelId)}/clear`,
|
||||
|
||||
// P2P
|
||||
p2pWorkers: '/api/p2p/workers',
|
||||
p2pFederation: '/api/p2p/federation',
|
||||
|
||||
6
core/http/react-ui/src/utils/format.js
vendored
6
core/http/react-ui/src/utils/format.js
vendored
@@ -12,6 +12,12 @@ export function percentColor(pct) {
|
||||
return 'var(--color-success)'
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts) {
|
||||
if (!ts) return '-'
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString() + '.' + String(d.getMilliseconds()).padStart(3, '0')
|
||||
}
|
||||
|
||||
export function vendorColor(vendor) {
|
||||
if (!vendor) return 'var(--color-accent)'
|
||||
const v = vendor.toLowerCase()
|
||||
|
||||
@@ -2,16 +2,29 @@ package routes
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/services"
|
||||
"github.com/mudler/LocalAI/core/trace"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
var backendLogsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
func RegisterUIRoutes(app *echo.Echo,
|
||||
cl *config.ModelConfigLoader,
|
||||
ml *model.ModelLoader,
|
||||
@@ -74,4 +87,119 @@ func RegisterUIRoutes(app *echo.Echo,
|
||||
return c.NoContent(204)
|
||||
})
|
||||
|
||||
// Backend logs REST endpoints
|
||||
app.GET("/api/backend-logs", func(c echo.Context) error {
|
||||
return c.JSON(200, ml.BackendLogs().ListModels())
|
||||
})
|
||||
|
||||
app.GET("/api/backend-logs/:modelId", func(c echo.Context) error {
|
||||
modelID := c.Param("modelId")
|
||||
return c.JSON(200, ml.BackendLogs().GetLines(modelID))
|
||||
})
|
||||
|
||||
app.POST("/api/backend-logs/:modelId/clear", func(c echo.Context) error {
|
||||
ml.BackendLogs().Clear(c.Param("modelId"))
|
||||
return c.NoContent(204)
|
||||
})
|
||||
|
||||
// Backend logs WebSocket endpoint for real-time streaming
|
||||
app.GET("/ws/backend-logs/:modelId", func(c echo.Context) error {
|
||||
modelID := c.Param("modelId")
|
||||
|
||||
ws, err := backendLogsUpgrader.Upgrade(c.Response(), c.Request(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
ws.SetReadLimit(4096)
|
||||
|
||||
// Set up ping/pong for keepalive
|
||||
ws.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
ws.SetPongHandler(func(string) error {
|
||||
ws.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
conn := &backendLogsConn{Conn: ws}
|
||||
|
||||
// Send existing lines as initial batch
|
||||
existingLines := ml.BackendLogs().GetLines(modelID)
|
||||
initialMsg := map[string]any{
|
||||
"type": "initial",
|
||||
"lines": existingLines,
|
||||
}
|
||||
if err := conn.writeJSON(initialMsg); err != nil {
|
||||
xlog.Debug("WebSocket backend-logs initial write failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe to new lines
|
||||
lineCh, unsubscribe := ml.BackendLogs().Subscribe(modelID)
|
||||
defer unsubscribe()
|
||||
|
||||
// Handle close from client side
|
||||
closeCh := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
_, _, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
close(closeCh)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Ping ticker for keepalive
|
||||
pingTicker := time.NewTicker(30 * time.Second)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
// Forward new lines to WebSocket
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-lineCh:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
lineMsg := map[string]any{
|
||||
"type": "line",
|
||||
"line": line,
|
||||
}
|
||||
if err := conn.writeJSON(lineMsg); err != nil {
|
||||
xlog.Debug("WebSocket backend-logs write error", "error", err)
|
||||
return nil
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
if err := conn.writePing(); err != nil {
|
||||
return nil
|
||||
}
|
||||
case <-closeCh:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// backendLogsConn wraps a websocket connection with a mutex for safe concurrent writes
|
||||
type backendLogsConn struct {
|
||||
*websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (c *backendLogsConn) writeJSON(v any) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal error: %w", err)
|
||||
}
|
||||
return c.Conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
func (c *backendLogsConn) writePing() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
return c.Conn.WriteMessage(websocket.PingMessage, nil)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ const (
|
||||
BackendTraceSoundGeneration BackendTraceType = "sound_generation"
|
||||
BackendTraceRerank BackendTraceType = "rerank"
|
||||
BackendTraceTokenize BackendTraceType = "tokenize"
|
||||
BackendTraceDetection BackendTraceType = "detection"
|
||||
BackendTraceModelLoad BackendTraceType = "model_load"
|
||||
)
|
||||
|
||||
type BackendTrace struct {
|
||||
|
||||
168
pkg/model/backend_log_store.go
Normal file
168
pkg/model/backend_log_store.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emirpasic/gods/v2/queues/circularbuffer"
|
||||
)
|
||||
|
||||
// BackendLogLine represents a single line of output from a backend process.
|
||||
type BackendLogLine struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Stream string `json:"stream"` // "stdout" or "stderr"
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// backendLogBuffer wraps a circular buffer for a single model's logs
|
||||
// and tracks subscribers for real-time streaming.
|
||||
type backendLogBuffer struct {
|
||||
mu sync.Mutex
|
||||
queue *circularbuffer.Queue[BackendLogLine]
|
||||
subscribers map[int]chan BackendLogLine
|
||||
nextSubID int
|
||||
}
|
||||
|
||||
// BackendLogStore stores per-model backend process output in circular buffers
|
||||
// and supports real-time subscriptions for WebSocket streaming.
|
||||
type BackendLogStore struct {
|
||||
mu sync.RWMutex // protects the buffers map only
|
||||
buffers map[string]*backendLogBuffer
|
||||
maxLines int
|
||||
}
|
||||
|
||||
// NewBackendLogStore creates a new BackendLogStore with a maximum number of
|
||||
// lines retained per model.
|
||||
func NewBackendLogStore(maxLinesPerModel int) *BackendLogStore {
|
||||
if maxLinesPerModel <= 0 {
|
||||
maxLinesPerModel = 1000
|
||||
}
|
||||
return &BackendLogStore{
|
||||
buffers: make(map[string]*backendLogBuffer),
|
||||
maxLines: maxLinesPerModel,
|
||||
}
|
||||
}
|
||||
|
||||
// getOrCreateBuffer returns the buffer for modelID, creating it if needed.
|
||||
func (s *BackendLogStore) getOrCreateBuffer(modelID string) *backendLogBuffer {
|
||||
s.mu.RLock()
|
||||
buf, ok := s.buffers[modelID]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
return buf
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
buf, ok = s.buffers[modelID]
|
||||
if !ok {
|
||||
buf = &backendLogBuffer{
|
||||
queue: circularbuffer.New[BackendLogLine](s.maxLines),
|
||||
subscribers: make(map[int]chan BackendLogLine),
|
||||
}
|
||||
s.buffers[modelID] = buf
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return buf
|
||||
}
|
||||
|
||||
// AppendLine adds a log line for the given model. The buffer is lazily created.
|
||||
// All active subscribers for this model are notified (non-blocking).
|
||||
func (s *BackendLogStore) AppendLine(modelID, stream, text string) {
|
||||
line := BackendLogLine{
|
||||
Timestamp: time.Now(),
|
||||
Stream: stream,
|
||||
Text: text,
|
||||
}
|
||||
|
||||
buf := s.getOrCreateBuffer(modelID)
|
||||
buf.mu.Lock()
|
||||
buf.queue.Enqueue(line)
|
||||
for _, ch := range buf.subscribers {
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
buf.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetLines returns a copy of all log lines for a model, or an empty slice.
|
||||
func (s *BackendLogStore) GetLines(modelID string) []BackendLogLine {
|
||||
s.mu.RLock()
|
||||
buf, ok := s.buffers[modelID]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return []BackendLogLine{}
|
||||
}
|
||||
|
||||
buf.mu.Lock()
|
||||
lines := buf.queue.Values()
|
||||
buf.mu.Unlock()
|
||||
return lines
|
||||
}
|
||||
|
||||
// ListModels returns a sorted list of model IDs that have log buffers.
|
||||
func (s *BackendLogStore) ListModels() []string {
|
||||
s.mu.RLock()
|
||||
models := make([]string, 0, len(s.buffers))
|
||||
for id := range s.buffers {
|
||||
models = append(models, id)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
sort.Strings(models)
|
||||
return models
|
||||
}
|
||||
|
||||
// Clear removes all log lines for a model but keeps the buffer entry.
|
||||
func (s *BackendLogStore) Clear(modelID string) {
|
||||
s.mu.RLock()
|
||||
buf, ok := s.buffers[modelID]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
buf.mu.Lock()
|
||||
buf.queue.Clear()
|
||||
buf.mu.Unlock()
|
||||
}
|
||||
|
||||
// Remove deletes the buffer entry for a model entirely.
|
||||
func (s *BackendLogStore) Remove(modelID string) {
|
||||
s.mu.Lock()
|
||||
if buf, ok := s.buffers[modelID]; ok {
|
||||
buf.mu.Lock()
|
||||
for _, ch := range buf.subscribers {
|
||||
close(ch)
|
||||
}
|
||||
buf.mu.Unlock()
|
||||
delete(s.buffers, modelID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives new log lines for the given model
|
||||
// in real-time, plus an unsubscribe function. The channel has a buffer of 100
|
||||
// lines to absorb short bursts without blocking the writer.
|
||||
func (s *BackendLogStore) Subscribe(modelID string) (chan BackendLogLine, func()) {
|
||||
ch := make(chan BackendLogLine, 100)
|
||||
|
||||
buf := s.getOrCreateBuffer(modelID)
|
||||
buf.mu.Lock()
|
||||
id := buf.nextSubID
|
||||
buf.nextSubID++
|
||||
buf.subscribers[id] = ch
|
||||
buf.mu.Unlock()
|
||||
|
||||
unsubscribe := func() {
|
||||
buf.mu.Lock()
|
||||
if _, exists := buf.subscribers[id]; exists {
|
||||
delete(buf.subscribers, id)
|
||||
close(ch)
|
||||
}
|
||||
buf.mu.Unlock()
|
||||
}
|
||||
|
||||
return ch, unsubscribe
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
@@ -33,6 +34,8 @@ type ModelLoader struct {
|
||||
lruEvictionMaxRetries int // Maximum number of retries when waiting for busy models
|
||||
lruEvictionRetryInterval time.Duration // Interval between retries when waiting for busy models
|
||||
onUnloadHooks []ModelUnloadHook
|
||||
backendLogs *BackendLogStore
|
||||
backendLoggingEnabled atomic.Bool
|
||||
}
|
||||
|
||||
// NewModelLoader creates a new ModelLoader instance.
|
||||
@@ -45,6 +48,7 @@ func NewModelLoader(system *system.SystemState) *ModelLoader {
|
||||
externalBackends: make(map[string]string),
|
||||
lruEvictionMaxRetries: 30, // Default: 30 retries
|
||||
lruEvictionRetryInterval: 1 * time.Second, // Default: 1 second
|
||||
backendLogs: NewBackendLogStore(1000),
|
||||
}
|
||||
|
||||
return nml
|
||||
@@ -72,6 +76,18 @@ func (ml *ModelLoader) GetWatchDog() *WatchDog {
|
||||
return ml.wd
|
||||
}
|
||||
|
||||
func (ml *ModelLoader) BackendLogs() *BackendLogStore {
|
||||
return ml.backendLogs
|
||||
}
|
||||
|
||||
func (ml *ModelLoader) SetBackendLoggingEnabled(enabled bool) {
|
||||
ml.backendLoggingEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
func (ml *ModelLoader) BackendLoggingEnabled() bool {
|
||||
return ml.backendLoggingEnabled.Load()
|
||||
}
|
||||
|
||||
// SetLRUEvictionRetrySettings updates the LRU eviction retry settings
|
||||
func (ml *ModelLoader) SetLRUEvictionRetrySettings(maxRetries int, retryInterval time.Duration) {
|
||||
ml.mu.Lock()
|
||||
|
||||
@@ -159,19 +159,27 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string
|
||||
go func() {
|
||||
t, err := tail.TailFile(grpcControlProcess.StderrPath(), tail.Config{Follow: true})
|
||||
if err != nil {
|
||||
xlog.Debug("Could not tail stderr")
|
||||
xlog.Error("Could not tail stderr", "process", grpcProcess)
|
||||
return
|
||||
}
|
||||
for line := range t.Lines {
|
||||
xlog.Debug("GRPC stderr", "id", strings.Join([]string{id, serverAddress}, "-"), "line", line.Text)
|
||||
if ml.backendLogs != nil && ml.backendLoggingEnabled.Load() {
|
||||
ml.backendLogs.AppendLine(id, "stderr", line.Text)
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
t, err := tail.TailFile(grpcControlProcess.StdoutPath(), tail.Config{Follow: true})
|
||||
if err != nil {
|
||||
xlog.Debug("Could not tail stdout")
|
||||
xlog.Error("Could not tail stdout", "process", grpcProcess)
|
||||
return
|
||||
}
|
||||
for line := range t.Lines {
|
||||
xlog.Debug("GRPC stdout", "id", strings.Join([]string{id, serverAddress}, "-"), "line", line.Text)
|
||||
if ml.backendLogs != nil && ml.backendLoggingEnabled.Load() {
|
||||
ml.backendLogs.AppendLine(id, "stdout", line.Text)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ func main() {
|
||||
config.WithDynamicConfigDir(dataDir),
|
||||
config.WithGeneratedContentDir(generatedDir),
|
||||
config.EnableTracing,
|
||||
config.EnableBackendLogging,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error creating application: %v\n", err)
|
||||
|
||||
Reference in New Issue
Block a user