diff --git a/core/application/startup.go b/core/application/startup.go index 19966ecb23f8..0d69763b486e 100644 --- a/core/application/startup.go +++ b/core/application/startup.go @@ -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") } diff --git a/core/backend/detection.go b/core/backend/detection.go index 1b1991824145..c7f866862b82 100644 --- a/core/backend/detection.go +++ b/core/backend/detection.go @@ -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 } diff --git a/core/backend/embeddings.go b/core/backend/embeddings.go index 2ba5049f240d..382f8f3583bc 100644 --- a/core/backend/embeddings.go +++ b/core/backend/embeddings.go @@ -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 } diff --git a/core/backend/image.go b/core/backend/image.go index 89ea14cb190e..44ca010c218c 100644 --- a/core/backend/image.go +++ b/core/backend/image.go @@ -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 } diff --git a/core/backend/llm.go b/core/backend/llm.go index 1316f72e4bf9..b82533a9fdc8 100644 --- a/core/backend/llm.go +++ b/core/backend/llm.go @@ -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 } diff --git a/core/backend/options.go b/core/backend/options.go index 85c67f60d0e8..4275a6f07495 100644 --- a/core/backend/options.go +++ b/core/backend/options.go @@ -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 == "" { diff --git a/core/backend/rerank.go b/core/backend/rerank.go index 2ff10120fdad..4b8f8b288b48 100644 --- a/core/backend/rerank.go +++ b/core/backend/rerank.go @@ -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 } diff --git a/core/backend/soundgeneration.go b/core/backend/soundgeneration.go index f2b03c45c5fd..f7f4d2f8228a 100644 --- a/core/backend/soundgeneration.go +++ b/core/backend/soundgeneration.go @@ -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 } diff --git a/core/backend/token_metrics.go b/core/backend/token_metrics.go index c81f57cab50f..4a9289eecb03 100644 --- a/core/backend/token_metrics.go +++ b/core/backend/token_metrics.go @@ -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 } diff --git a/core/backend/tokenize.go b/core/backend/tokenize.go index 60d8568f2ae8..f70ca14e2b6d 100644 --- a/core/backend/tokenize.go +++ b/core/backend/tokenize.go @@ -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 } diff --git a/core/backend/transcript.go b/core/backend/transcript.go index 7568e4e40706..6aa903e4a0de 100644 --- a/core/backend/transcript.go +++ b/core/backend/transcript.go @@ -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 } diff --git a/core/backend/tts.go b/core/backend/tts.go index 69193db12a5d..2f3d31193b91 100644 --- a/core/backend/tts.go +++ b/core/backend/tts.go @@ -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 } diff --git a/core/backend/vad.go b/core/backend/vad.go index 37859931dc1b..bcf6f5976430 100644 --- a/core/backend/vad.go +++ b/core/backend/vad.go @@ -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 } diff --git a/core/backend/video.go b/core/backend/video.go index 277320515c2a..65677f0552d8 100644 --- a/core/backend/video.go +++ b/core/backend/video.go @@ -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 } diff --git a/core/config/application_config.go b/core/config/application_config.go index 8edc22c00483..74c3511a6594 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -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 } diff --git a/core/config/runtime_settings.go b/core/config/runtime_settings.go index ea1765719961..7637a0d94696 100644 --- a/core/config/runtime_settings.go +++ b/core/config/runtime_settings.go @@ -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"` diff --git a/core/http/endpoints/localai/settings.go b/core/http/endpoints/localai/settings.go index 0114b31ac485..2428c9cd4897 100644 --- a/core/http/endpoints/localai/settings.go +++ b/core/http/endpoints/localai/settings.go @@ -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() diff --git a/core/http/middleware/trace.go b/core/http/middleware/trace.go index 2d7bcf16719d..800b824c8789 100644 --- a/core/http/middleware/trace.go +++ b/core/http/middleware/trace.go @@ -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 } } } diff --git a/core/http/react-ui/e2e/backend-logs.spec.js b/core/http/react-ui/e2e/backend-logs.spec.js new file mode 100644 index 000000000000..3780cad550f7 --- /dev/null +++ b/core/http/react-ui/e2e/backend-logs.spec.js @@ -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() + }) +}) diff --git a/core/http/react-ui/e2e/manage-logs-link.spec.js b/core/http/react-ui/e2e/manage-logs-link.spec.js new file mode 100644 index 000000000000..d4cff23f9282 --- /dev/null +++ b/core/http/react-ui/e2e/manage-logs-link.spec.js @@ -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\//) + }) +}) diff --git a/core/http/react-ui/e2e/settings-backend-logging.spec.js b/core/http/react-ui/e2e/settings-backend-logging.spec.js new file mode 100644 index 000000000000..b01b00f3908b --- /dev/null +++ b/core/http/react-ui/e2e/settings-backend-logging.spec.js @@ -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 }) + }) +}) diff --git a/core/http/react-ui/e2e/traces-errors.spec.js b/core/http/react-ui/e2e/traces-errors.spec.js new file mode 100644 index 000000000000..af820663536c --- /dev/null +++ b/core/http/react-ui/e2e/traces-errors.spec.js @@ -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() + }) +}) diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 2ff91d98bc08..375575c107ec 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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; } diff --git a/core/http/react-ui/src/pages/BackendLogs.jsx b/core/http/react-ui/src/pages/BackendLogs.jsx new file mode 100644 index 000000000000..ab38d8cd8afe --- /dev/null +++ b/core/http/react-ui/src/pages/BackendLogs.jsx @@ -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 ( +
Backend process output
++ {filter !== 'all' + ? `No ${filter} output. Try switching to "All".` + : 'Log output will appear here as the backend process runs.'} +
++ View backend logs for a specific model from the{' '} + System page. +
+| Description | @@ -256,12 +257,21 @@ export default function Backends() {|||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| + + | {/* Icon */}{b.icon ? ( @@ -279,12 +289,7 @@ export default function Backends() { {/* Name */} | - setSelectedBackend(b)} - > - {b.name || b.id} - + {b.name || b.id} | {/* Description */} @@ -343,10 +348,7 @@ export default function Backends() { {/* Actions */}
-
-
+ e.stopPropagation()}>
{b.installed ? (
<>
|
||||||||
|
+ |
+ |||||||||||
| + {label} + | +{children} | +||||||||||
| handleSort('name')}> Model Name {sort === 'name' && } @@ -335,14 +336,23 @@ export default function Models() { | ||||||||
|---|---|---|---|---|---|---|---|---|
| + + | {/* Icon */}
-
-
+ e.stopPropagation()}>
{model.installed ? (
<>
|
|||||||
|
+ |
+ ||||||||
| + {label} + | +{children} | +|||||||
| Filename | +URI | +SHA256 | +
|---|---|---|
| {f.filename || '—'} | +{f.uri || '—'} | ++ {f.sha256 ? f.sha256.substring(0, 16) + '...' : '—'} + | +