fix(ui): Add tracing inline settings back and create UI tests (#9027)

Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
Richard Palethorpe
2026-03-16 16:51:06 +00:00
committed by GitHub
parent d8161bfe57
commit 3d9ccd1ddc
25 changed files with 789 additions and 235 deletions

72
.github/workflows/tests-ui-e2e.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
---
name: 'UI E2E Tests'
on:
pull_request:
paths:
- 'core/http/**'
- 'tests/e2e-ui/**'
- 'tests/e2e/mock-backend/**'
push:
branches:
- master
concurrency:
group: ci-tests-ui-e2e-${{ github.head_ref || github.ref }}-${{ github.repository }}
cancel-in-progress: true
jobs:
tests-ui-e2e:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.26.x']
steps:
- name: Clone
uses: actions/checkout@v6
with:
submodules: true
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Proto Dependencies
run: |
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
- name: System Dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential libopus-dev
- name: Build UI test server
run: PATH="$PATH:$HOME/go/bin" make build-ui-test-server
- name: Install Playwright
working-directory: core/http/react-ui
run: |
npm install
npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: core/http/react-ui
run: npx playwright test
- name: Upload Playwright report
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: core/http/react-ui/playwright-report/
retention-days: 7
- name: Setup tmate session if tests fail
if: ${{ failure() }}
uses: mxschmitt/action-tmate@v3.23
with:
detached: true
connect-timeout-seconds: 180
limit-access-to-actor: true

5
.gitignore vendored
View File

@@ -72,3 +72,8 @@ core/http/react-ui/dist
# Extracted backend binaries for container-based testing
local-backends/
# UI E2E test artifacts
tests/e2e-ui/ui-test-server
core/http/react-ui/playwright-report/
core/http/react-ui/test-results/

View File

@@ -646,6 +646,23 @@ build-mock-backend: protogen-go
clean-mock-backend:
rm -f tests/e2e/mock-backend/mock-backend
########################################################
### UI E2E Test Server
########################################################
build-ui-test-server: build-mock-backend react-ui protogen-go
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui
test-ui-e2e: build-ui-test-server
cd core/http/react-ui && npm install && npx playwright install --with-deps chromium && npx playwright test
test-ui-e2e-docker:
docker build -t localai-ui-e2e -f tests/e2e-ui/Dockerfile .
docker run --rm localai-ui-e2e
clean-ui-test-server:
rm -f tests/e2e-ui/ui-test-server
########################################################
### END Backends
########################################################

View File

@@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test'
test.describe('Navigation', () => {
test('/ redirects to /app', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/app/)
})
test('/app shows home page with LocalAI title', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.sidebar')).toBeVisible()
await expect(page.getByRole('heading', { name: 'How can I help you today?' })).toBeVisible()
})
test('sidebar traces link navigates to /app/traces', async ({ page }) => {
await page.goto('/app')
const tracesLink = page.locator('a.nav-item[href="/app/traces"]')
await expect(tracesLink).toBeVisible()
await tracesLink.click()
await expect(page).toHaveURL(/\/app\/traces/)
await expect(page.getByRole('heading', { name: 'Traces', exact: true })).toBeVisible()
})
})

View File

@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test'
test.describe('Traces Settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/traces')
// Wait for settings panel to load
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
})
test('settings panel is visible on page load', async ({ page }) => {
await expect(page.locator('text=Tracing is')).toBeVisible()
})
test('expand and collapse settings', async ({ page }) => {
// The test server starts with tracing enabled, so the panel starts collapsed
const settingsHeader = page.locator('button', { hasText: 'Tracing is' })
// Click to expand
await settingsHeader.click()
await expect(page.locator('text=Enable Tracing')).toBeVisible()
// Click to collapse
await settingsHeader.click()
await expect(page.locator('text=Enable Tracing')).not.toBeVisible()
})
test('toggle tracing on and off', async ({ page }) => {
// Expand settings
const settingsHeader = page.locator('button', { hasText: 'Tracing is' })
await settingsHeader.click()
await expect(page.locator('text=Enable Tracing')).toBeVisible()
// The Toggle component is a <label> wrapping a hidden checkbox.
// Target the checkbox within the settings panel.
const checkbox = page.locator('input[type="checkbox"]')
// Initially enabled (server starts with tracing on)
await expect(checkbox).toBeChecked()
// Click the label (parent) to toggle off
await checkbox.locator('..').click()
await expect(checkbox).not.toBeChecked()
// Click again to re-enable
await checkbox.locator('..').click()
await expect(checkbox).toBeChecked()
})
test('set max items value', async ({ page }) => {
// Expand settings
await page.locator('button', { hasText: 'Tracing is' }).click()
await expect(page.locator('text=Enable Tracing')).toBeVisible()
const maxItemsInput = page.locator('input[type="number"]')
await maxItemsInput.fill('500')
await expect(maxItemsInput).toHaveValue('500')
})
test('save shows toast', async ({ page }) => {
// Expand settings
await page.locator('button', { hasText: 'Tracing is' }).click()
// Click save
await page.locator('button', { hasText: 'Save' }).click()
// Verify toast appears
await expect(page.locator('text=Tracing settings saved')).toBeVisible({ timeout: 5_000 })
})
test('panel collapses after save when tracing is enabled', async ({ page }) => {
// Expand settings
await page.locator('button', { hasText: 'Tracing is' }).click()
await expect(page.locator('text=Enable Tracing')).toBeVisible()
// Tracing is already enabled; save
await page.locator('button', { hasText: 'Save' }).click()
// Panel should collapse
await expect(page.locator('text=Enable Tracing')).not.toBeVisible()
})
test('panel stays expanded after save when tracing is off', async ({ page }) => {
// Expand settings
await page.locator('button', { hasText: 'Tracing is' }).click()
await expect(page.locator('text=Enable Tracing')).toBeVisible()
// Toggle tracing off
await page.locator('input[type="checkbox"]').locator('..').click()
// Save
await page.locator('button', { hasText: 'Save' }).click()
// Panel should stay expanded since tracing is now disabled
await expect(page.locator('text=Enable Tracing')).toBeVisible()
})
})

View File

@@ -20,6 +20,7 @@
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@playwright/test": "^1.52.0",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.27.0",
"eslint-plugin-react-hooks": "^5.2.0",
@@ -1046,6 +1047,22 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -3051,6 +3068,53 @@
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
"lint": "eslint .",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"react": "^19.1.0",
@@ -27,6 +29,7 @@
"@eslint/js": "^9.27.0",
"globals": "^16.1.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20"
"eslint-plugin-react-refresh": "^0.4.20",
"@playwright/test": "1.52.0"
}
}

View File

@@ -0,0 +1,24 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'html' : 'list',
use: {
baseURL: 'http://127.0.0.1:8089',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
webServer: process.env.PLAYWRIGHT_EXTERNAL_SERVER ? undefined : {
command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089',
port: 8089,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
})

View File

@@ -0,0 +1,20 @@
export default function Modal({ onClose, children, maxWidth = '600px' }) {
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--color-modal-backdrop)', backdropFilter: 'blur(4px)',
}} onClick={onClose}>
<div style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-lg)',
maxWidth, width: '90%', maxHeight: '80vh',
display: 'flex', flexDirection: 'column',
overflow: 'auto',
}} onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
export default function SettingRow({ label, description, children }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) 0',
borderBottom: '1px solid var(--color-border-subtle)',
}}>
<div style={{ flex: 1, marginRight: 'var(--spacing-md)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
export default function Toggle({ checked, onChange, disabled }) {
return (
<label style={{
position: 'relative', display: 'inline-block', width: 40, height: 22, cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}>
<input
type="checkbox"
checked={checked || false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
style={{ display: 'none' }}
/>
<span style={{
position: 'absolute', inset: 0, borderRadius: 22,
background: checked ? 'var(--color-primary)' : 'var(--color-toggle-off)',
transition: 'background 200ms',
}}>
<span style={{
position: 'absolute', top: 2, left: checked ? 20 : 2,
width: 18, height: 18, borderRadius: '50%',
background: 'var(--color-text-inverse)', transition: 'left 200ms',
boxShadow: 'var(--shadow-sm)',
}} />
</span>
</label>
)
}

View File

@@ -2,6 +2,8 @@ import { useState, useEffect, useMemo } from 'react'
import { useParams, useNavigate, useLocation, useOutletContext } from 'react-router-dom'
import { agentsApi } from '../utils/api'
import SearchableModelSelect from '../components/SearchableModelSelect'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
// --- MCP STDIO helpers ---
@@ -50,53 +52,6 @@ function buildStdioJson(list) {
return JSON.stringify({ mcpServers }, null, 2)
}
// --- Shared UI components (same style as Settings page) ---
function Toggle({ checked, onChange, disabled }) {
return (
<label style={{
position: 'relative', display: 'inline-block', width: 40, height: 22, cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}>
<input
type="checkbox"
checked={checked || false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
style={{ display: 'none' }}
/>
<span style={{
position: 'absolute', inset: 0, borderRadius: 22,
background: checked ? 'var(--color-primary)' : 'var(--color-toggle-off)',
transition: 'background 200ms',
}}>
<span style={{
position: 'absolute', top: 2, left: checked ? 20 : 2,
width: 18, height: 18, borderRadius: '50%',
background: '#fff', transition: 'left 200ms',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
}} />
</span>
</label>
)
}
function SettingRow({ label, description, children }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) 0',
borderBottom: '1px solid var(--color-border-subtle)',
}}>
<div style={{ flex: 1, marginRight: 'var(--spacing-md)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
</div>
)
}
// --- Form field components ---
function FormField({ field, value, onChange, disabled }) {

View File

@@ -331,7 +331,7 @@ export default function AgentJobDetails() {
Error
</h3>
<pre style={{
background: 'rgba(239,68,68,0.05)', padding: 'var(--spacing-sm)',
background: 'var(--color-error-light)', padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-md)', fontSize: '0.8125rem',
whiteSpace: 'pre-wrap', overflow: 'auto', color: 'var(--color-error)',
}}>

View File

@@ -4,6 +4,7 @@ import { agentJobsApi, modelsApi } from '../utils/api'
import { useModels } from '../hooks/useModels'
import LoadingSpinner from '../components/LoadingSpinner'
import { fileToBase64 } from '../utils/api'
import Modal from '../components/Modal'
export default function AgentJobs() {
const { addToast } = useOutletContext()
@@ -187,7 +188,7 @@ export default function AgentJobs() {
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
<i className="fas fa-store" /> Browse Models
</button>
<a className="btn btn-secondary" href="https://localai.io/features/agent-jobs/" target="_blank" rel="noopener noreferrer">
<a className="btn btn-secondary" href="https://localai.io/features/agents/" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> Documentation
</a>
</div>
@@ -222,7 +223,7 @@ export default function AgentJobs() {
<button className="btn btn-primary" onClick={() => navigate('/app/manage')}>
<i className="fas fa-cog" /> Manage Models
</button>
<a className="btn btn-secondary" href="https://localai.io/features/agent-jobs/" target="_blank" rel="noopener noreferrer">
<a className="btn btn-secondary" href="https://localai.io/features/agents/" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> Documentation
</a>
</div>
@@ -408,11 +409,8 @@ export default function AgentJobs() {
{/* Execute Task Modal */}
{executeModal && (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setExecuteModal(null)}>
<div className="card" style={{ maxWidth: 600, width: '90%', maxHeight: '80vh', overflow: 'auto' }} onClick={e => e.stopPropagation()}>
<Modal onClose={() => setExecuteModal(null)}>
<div style={{ padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600 }}>
<i className="fas fa-play" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
@@ -489,7 +487,7 @@ export default function AgentJobs() {
</button>
</div>
</div>
</div>
</Modal>
)}
</div>
)

View File

@@ -4,6 +4,7 @@ import { backendsApi } from '../utils/api'
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()
@@ -390,11 +391,8 @@ export default function Backends() {
{/* Detail Modal */}
{selectedBackend && (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setSelectedBackend(null)}>
<div className="card" style={{ maxWidth: 600, width: '90%', maxHeight: '80vh', overflow: 'auto' }} onClick={e => e.stopPropagation()}>
<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 ? (
@@ -489,7 +487,7 @@ export default function Backends() {
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(null)}>Close</button>
</div>
</div>
</div>
</Modal>
)}
</div>
)

View File

@@ -51,12 +51,9 @@ export default function Manage() {
}, [])
useEffect(() => {
if (activeTab === 'models') {
fetchLoadedModels()
} else {
fetchBackends()
}
}, [activeTab, fetchLoadedModels, fetchBackends])
fetchLoadedModels()
fetchBackends()
}, [fetchLoadedModels, fetchBackends])
const handleStopModel = async (modelName) => {
if (!confirm(`Stop model ${modelName}?`)) return

View File

@@ -4,6 +4,7 @@ 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'
const LOADING_PHRASES = [
@@ -494,83 +495,71 @@ export default function Models() {
{/* Detail Modal */}
{selectedModel && (
<div style={{
position: 'fixed', inset: 0, zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
}} onClick={() => setSelectedModel(null)}>
<Modal onClose={() => setSelectedModel(null)}>
{/* Modal header */}
<div style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-lg)',
maxWidth: '600px', width: '90%', maxHeight: '80vh',
display: 'flex', flexDirection: 'column',
}} onClick={e => e.stopPropagation()}>
{/* 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>
)}
{/* Description */}
{selectedModel.description && (
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', lineHeight: 1.6, marginBottom: 'var(--spacing-md)' }}>
{selectedModel.description}
</p>
)}
{/* 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>
))}
</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}
</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>
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>
</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>
)}
{/* Description */}
{selectedModel.description && (
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', lineHeight: 1.6, marginBottom: 'var(--spacing-md)' }}>
{selectedModel.description}
</p>
)}
{/* 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>
))}
</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}
</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>
)}
</div>
)

View File

@@ -7,7 +7,7 @@ function NodeCard({ node, label, iconColor, iconBg }) {
return (
<div style={{
background: 'var(--color-bg-primary)',
border: `1px solid ${node.isOnline ? 'rgba(34,197,94,0.5)' : 'rgba(239,68,68,0.5)'}`,
border: `1px solid ${node.isOnline ? 'var(--color-success-border)' : 'var(--color-error-border)'}`,
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
transition: 'border-color 200ms',
@@ -211,7 +211,7 @@ export default function P2P() {
<div className="card" style={{ textAlign: 'center', padding: 'var(--spacing-md)' }}>
<div style={{
width: 40, height: 40, borderRadius: 'var(--radius-md)', margin: '0 auto var(--spacing-sm)',
background: 'rgba(34,197,94,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--color-success-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-share-alt" style={{ color: 'var(--color-success)', fontSize: '1.25rem' }} />
</div>
@@ -305,7 +305,7 @@ export default function P2P() {
{/* Network Token */}
<div style={{
background: 'var(--color-bg-secondary)', border: '1px solid rgba(139,92,246,0.2)',
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-accent-border)',
borderRadius: 'var(--radius-lg)', padding: 'var(--spacing-lg)', marginBottom: 'var(--spacing-xl)',
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
@@ -420,7 +420,7 @@ export default function P2P() {
{/* ── Federation Tab ── */}
{activeTab === 'federation' && (
<div style={{
background: 'var(--color-bg-secondary)', border: '1px solid rgba(99,102,241,0.2)',
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-accent-border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{ padding: 'var(--spacing-lg)', borderBottom: '1px solid var(--color-border-subtle)' }}>
@@ -433,7 +433,7 @@ export default function P2P() {
<div style={{ textAlign: 'center' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
background: 'rgba(245,158,11,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--color-warning-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto var(--spacing-xs)',
}}>
<i className="fas fa-user" style={{ color: 'var(--color-warning)', fontSize: '1rem' }} />
@@ -444,7 +444,7 @@ export default function P2P() {
<div style={{ textAlign: 'center' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
background: 'rgba(34,197,94,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--color-success-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto var(--spacing-xs)', border: '2px solid var(--color-success)',
}}>
<i className="fas fa-scale-balanced" style={{ color: 'var(--color-success)', fontSize: '1rem' }} />
@@ -514,7 +514,7 @@ export default function P2P() {
}}>
{/* Step 1 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-sm)' }}>
<StepNumber n={1} bg="rgba(34,197,94,0.15)" color="var(--color-success)" />
<StepNumber n={1} bg="var(--color-success-light)" color="var(--color-success)" />
<h4 style={{ fontSize: '1rem', fontWeight: 700 }}>
Start the Federated Server <span style={{ fontSize: '0.8125rem', fontWeight: 400, color: 'var(--color-text-muted)' }}>(load balancer)</span>
</h4>
@@ -567,12 +567,12 @@ export default function P2P() {
{/* ── Model Sharding Tab ── */}
{activeTab === 'sharding' && (
<div style={{
background: 'var(--color-bg-secondary)', border: '1px solid rgba(139,92,246,0.2)',
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-accent-border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{ padding: 'var(--spacing-lg)', borderBottom: '1px solid var(--color-border-subtle)' }}>
<div style={{
background: 'var(--color-accent-light)', border: '1px solid rgba(139,92,246,0.3)',
background: 'var(--color-accent-light)', border: '1px solid var(--color-accent-border)',
borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm) var(--spacing-md)',
fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)',
}}>
@@ -614,7 +614,7 @@ export default function P2P() {
<div style={{
width: 56, height: 36, borderRadius: 'var(--radius-sm)',
background: 'var(--color-accent-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid rgba(139,92,246,0.3)',
border: '1px solid var(--color-accent-border)',
}}>
<i className="fas fa-microchip" style={{ color: 'var(--color-accent)', fontSize: '0.75rem' }} />
</div>
@@ -662,7 +662,7 @@ export default function P2P() {
{/* ── MLX Distributed Workers Section ── */}
<div style={{ padding: 'var(--spacing-lg)', borderBottom: '1px solid var(--color-border-subtle)' }}>
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, marginBottom: 'var(--spacing-sm)', display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-apple-whole" style={{ color: 'rgba(245,158,11,1)' }} />
<i className="fas fa-apple-whole" style={{ color: 'var(--color-warning)' }} />
MLX Distributed Workers
</h3>
@@ -693,10 +693,10 @@ export default function P2P() {
<div key={i} style={{ textAlign: 'center' }}>
<div style={{
width: 64, height: 36, borderRadius: 'var(--radius-sm)',
background: 'rgba(245,158,11,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid rgba(245,158,11,0.3)',
background: 'var(--color-warning-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid var(--color-warning-border)',
}}>
<i className="fas fa-microchip" style={{ color: 'rgba(245,158,11,1)', fontSize: '0.75rem' }} />
<i className="fas fa-microchip" style={{ color: 'var(--color-warning)', fontSize: '0.75rem' }} />
</div>
<div style={{ fontSize: '0.5625rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{label}</div>
</div>
@@ -733,7 +733,7 @@ export default function P2P() {
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 'var(--spacing-md)' }}>
{mlxWorkers.map((node, i) => (
<NodeCard key={node.id || i} node={node} label={`MLX Rank ${i + 1}`} iconColor="rgba(245,158,11,1)" iconBg="rgba(245,158,11,0.1)" />
<NodeCard key={node.id || i} node={node} label={`MLX Rank ${i + 1}`} iconColor="var(--color-warning)" iconBg="var(--color-warning-light)" />
))}
</div>
)}
@@ -763,7 +763,7 @@ export default function P2P() {
<div style={{
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-lg)',
border: '1px solid rgba(245,158,11,0.3)', padding: 'var(--spacing-lg)',
border: '1px solid var(--color-warning-border)', padding: 'var(--spacing-lg)',
}}>
<h4 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>MLX Distributed Worker</h4>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: 'var(--spacing-sm)' }}>
@@ -776,7 +776,7 @@ export default function P2P() {
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem', marginTop: 'var(--spacing-sm)' }}>
For more information, see the{' '}
<a href="https://localai.io/features/mlx-distributed/" target="_blank" rel="noopener noreferrer"
style={{ color: 'rgba(245,158,11,1)' }}>MLX Distributed</a> docs.
style={{ color: 'var(--color-warning)' }}>MLX Distributed</a> docs.
</p>
</div>
</div>

View File

@@ -3,53 +3,10 @@ import { useOutletContext } from 'react-router-dom'
import { settingsApi, resourcesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import SearchableModelSelect from '../components/SearchableModelSelect'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
import { formatBytes, percentColor } from '../utils/format'
function Toggle({ checked, onChange, disabled }) {
return (
<label style={{
position: 'relative', display: 'inline-block', width: 40, height: 22, cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}>
<input
type="checkbox"
checked={checked || false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
style={{ display: 'none' }}
/>
<span style={{
position: 'absolute', inset: 0, borderRadius: 22,
background: checked ? 'var(--color-primary)' : 'var(--color-toggle-off)',
transition: 'background 200ms',
}}>
<span style={{
position: 'absolute', top: 2, left: checked ? 20 : 2,
width: 18, height: 18, borderRadius: '50%',
background: '#fff', transition: 'left 200ms',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
}} />
</span>
</label>
)
}
function SettingRow({ label, description, children }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) 0',
borderBottom: '1px solid var(--color-border-subtle)',
}}>
<div style={{ flex: 1, marginRight: 'var(--spacing-md)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
</div>
)
}
const SECTIONS = [
{ id: 'watchdog', icon: 'fa-shield-halved', color: 'var(--color-primary)', label: 'Watchdog' },
{ id: 'memory', icon: 'fa-memory', color: 'var(--color-accent)', label: 'Memory' },

View File

@@ -5,10 +5,10 @@ import { realtimeApi } from '../utils/api'
const STATUS_STYLES = {
disconnected: { icon: 'fa-solid fa-circle', color: 'var(--color-text-secondary)', bg: 'transparent' },
connecting: { icon: 'fa-solid fa-spinner fa-spin', color: 'var(--color-primary)', bg: 'var(--color-primary-light)' },
connected: { icon: 'fa-solid fa-circle', color: 'var(--color-success)', bg: 'rgba(34,197,94,0.1)' },
listening: { icon: 'fa-solid fa-microphone', color: 'var(--color-success)', bg: 'rgba(34,197,94,0.1)' },
connected: { icon: 'fa-solid fa-circle', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
listening: { icon: 'fa-solid fa-microphone', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
thinking: { icon: 'fa-solid fa-brain fa-beat', color: 'var(--color-primary)', bg: 'var(--color-primary-light)' },
speaking: { icon: 'fa-solid fa-volume-high fa-beat-fade', color: 'var(--color-accent)', bg: 'rgba(168,85,247,0.1)' },
speaking: { icon: 'fa-solid fa-volume-high fa-beat-fade', color: 'var(--color-accent)', bg: 'var(--color-accent-light)' },
error: { icon: 'fa-solid fa-circle', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
}

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useOutletContext } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { tracesApi, settingsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
const AUDIO_DATA_KEYS = new Set([
'audio_wav_base64', 'audio_duration_s', 'audio_snippet_s',
@@ -211,7 +212,7 @@ function BackendTraceDetail({ trace }) {
{/* Error banner */}
{trace.error && (
<div style={{
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
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)',
}}>
@@ -270,14 +271,35 @@ export default function Traces() {
const [loading, setLoading] = useState(true)
const [expandedRow, setExpandedRow] = useState(null)
const [tracingEnabled, setTracingEnabled] = useState(null)
const [settings, setSettings] = useState(null)
const [settingsExpanded, setSettingsExpanded] = useState(false)
const [saving, setSaving] = useState(false)
const refreshRef = useRef(null)
useEffect(() => {
settingsApi.get()
.then(data => setTracingEnabled(!!data.enable_tracing))
.then(data => {
setTracingEnabled(!!data.enable_tracing)
setSettings(data)
if (!data.enable_tracing) setSettingsExpanded(true)
})
.catch(() => {})
}, [])
const handleSaveSettings = async () => {
setSaving(true)
try {
await settingsApi.save(settings)
setTracingEnabled(!!settings.enable_tracing)
addToast('Tracing settings saved', 'success')
if (settings.enable_tracing) setSettingsExpanded(false)
} catch (err) {
addToast(`Save failed: ${err.message}`, 'error')
} finally {
setSaving(false)
}
}
const fetchTraces = useCallback(async () => {
try {
const [apiData, backendData] = await Promise.all([
@@ -356,30 +378,60 @@ export default function Traces() {
<button className="btn btn-secondary btn-sm" onClick={handleExport} disabled={traces.length === 0}><i className="fas fa-download" /> Export</button>
</div>
{tracingEnabled === false && (
{settings && (
<div style={{
background: 'rgba(234,179,8,0.1)', border: '1px solid rgba(234,179,8,0.3)',
borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm) var(--spacing-md)',
marginBottom: 'var(--spacing-md)', display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
border: `1px solid ${tracingEnabled ? 'var(--color-success-border)' : 'var(--color-warning-border)'}`,
borderRadius: 'var(--radius-md)',
marginBottom: 'var(--spacing-md)',
overflow: 'hidden',
}}>
<i className="fas fa-exclamation-triangle" style={{ color: '#facc15', flexShrink: 0 }} />
<span style={{ fontSize: '0.8125rem' }}>
Tracing is currently <strong>disabled</strong>. New requests will not be recorded.{' '}
<Link to="/settings" style={{ color: 'var(--color-primary)' }}>Enable in Settings</Link>
</span>
</div>
)}
{tracingEnabled === true && (
<div style={{
background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.25)',
borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm) var(--spacing-md)',
marginBottom: 'var(--spacing-md)', display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
}}>
<i className="fas fa-circle-check" style={{ color: 'var(--color-success)', flexShrink: 0 }} />
<span style={{ fontSize: '0.8125rem' }}>
Tracing is <strong>enabled</strong>. Requests are being recorded.{' '}
<Link to="/settings" style={{ color: 'var(--color-primary)' }}>Manage in Settings</Link>
</span>
<button
onClick={() => setSettingsExpanded(!settingsExpanded)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) var(--spacing-md)',
background: tracingEnabled ? 'var(--color-success-light)' : 'var(--color-warning-light)',
border: 'none', cursor: 'pointer',
color: 'var(--color-text-primary)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className={`fas ${tracingEnabled ? 'fa-circle-check' : 'fa-exclamation-triangle'}`}
style={{ color: tracingEnabled ? 'var(--color-success)' : 'var(--color-warning)', flexShrink: 0 }} />
<span style={{ fontSize: '0.8125rem', textAlign: 'left' }}>
Tracing is <strong>{tracingEnabled ? 'enabled' : 'disabled'}</strong>
{!tracingEnabled && ' — new requests will not be recorded'}
</span>
</div>
<i className={`fas fa-chevron-${settingsExpanded ? 'up' : 'down'}`}
style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', flexShrink: 0 }} />
</button>
{settingsExpanded && (
<div style={{ padding: '0 var(--spacing-md) var(--spacing-md)', background: 'var(--color-bg-secondary)', borderTop: '1px solid var(--color-border-subtle)' }}>
<SettingRow label="Enable Tracing" description="Record API requests, responses, and backend operations">
<Toggle
checked={settings.enable_tracing}
onChange={(v) => setSettings(prev => ({ ...prev, enable_tracing: v }))}
/>
</SettingRow>
<SettingRow label="Max Items" description="Maximum trace items to retain (0 = unlimited)">
<input
className="input"
type="number"
style={{ width: 120 }}
value={settings.tracing_max_items ?? ''}
onChange={(e) => setSettings(prev => ({ ...prev, tracing_max_items: parseInt(e.target.value) || 0 }))}
placeholder="100"
disabled={!settings.enable_tracing}
/>
</SettingRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 'var(--spacing-sm)' }}>
<button className="btn btn-primary btn-sm" onClick={handleSaveSettings} disabled={saving}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
</button>
</div>
</div>
)}
</div>
)}

View File

@@ -38,12 +38,18 @@
--color-success: #14B8A6;
--color-success-light: rgba(20, 184, 166, 0.1);
--color-success-border: rgba(20, 184, 166, 0.3);
--color-warning: #F59E0B;
--color-warning-light: rgba(245, 158, 11, 0.1);
--color-warning-border: rgba(245, 158, 11, 0.3);
--color-error: #EF4444;
--color-error-light: rgba(239, 68, 68, 0.1);
--color-error-border: rgba(239, 68, 68, 0.3);
--color-info: #38BDF8;
--color-info-light: rgba(56, 189, 248, 0.1);
--color-info-border: rgba(56, 189, 248, 0.3);
--color-accent-border: rgba(139, 92, 246, 0.3);
--color-modal-backdrop: rgba(0, 0, 0, 0.6);
--gradient-primary: linear-gradient(135deg, #38BDF8 0%, #8B5CF6 50%, #14B8A6 100%);
--gradient-hero: linear-gradient(135deg, #121212 0%, #1A1A1A 50%, #121212 100%);
@@ -117,12 +123,18 @@
--color-success: #0D9488;
--color-success-light: rgba(13, 148, 136, 0.1);
--color-success-border: rgba(13, 148, 136, 0.3);
--color-warning: #D97706;
--color-warning-light: rgba(217, 119, 6, 0.1);
--color-warning-border: rgba(217, 119, 6, 0.3);
--color-error: #DC2626;
--color-error-light: rgba(220, 38, 38, 0.1);
--color-error-border: rgba(220, 38, 38, 0.3);
--color-info: #0EA5E9;
--color-info-light: rgba(14, 165, 233, 0.1);
--color-info-border: rgba(14, 165, 233, 0.3);
--color-accent-border: rgba(124, 58, 237, 0.3);
--color-modal-backdrop: rgba(0, 0, 0, 0.5);
--gradient-primary: linear-gradient(135deg, #0EA5E9 0%, #7C3AED 50%, #0D9488 100%);
--gradient-hero: linear-gradient(135deg, #F8FAFC 0%, #FFFFFF 50%, #F8FAFC 100%);

1
tests/e2e-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
ui-test-server

92
tests/e2e-ui/Dockerfile Normal file
View File

@@ -0,0 +1,92 @@
ARG GO_VERSION=1.25.4
ARG PLAYWRIGHT_VERSION=v1.52.0
###################################
# Stage 1: Build React UI
###################################
FROM node:25-slim AS react-ui-builder
WORKDIR /app
COPY core/http/react-ui/package*.json ./
RUN npm install
COPY core/http/react-ui/ ./
RUN npm run build
###################################
# Stage 2: Build Go test server + mock backend
###################################
FROM ubuntu:24.04 AS go-builder
ARG GO_VERSION
ARG TARGETARCH
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential ca-certificates curl git unzip libopus-dev pkg-config && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Go
RUN curl -L -s https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz | tar -C /usr/local -xz
ENV PATH=$PATH:/root/go/bin:/usr/local/go/bin
# Install protoc + Go gRPC tools
RUN <<EOT bash
if [ "amd64" = "$TARGETARCH" ]; then
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v27.1/protoc-27.1-linux-x86_64.zip -o protoc.zip
elif [ "arm64" = "$TARGETARCH" ]; then
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v27.1/protoc-27.1-linux-aarch_64.zip -o protoc.zip
fi
unzip -j -d /usr/local/bin protoc.zip bin/protoc && rm protoc.zip
EOT
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2 && \
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
WORKDIR /build
COPY . .
# Copy pre-built React UI so it gets embedded
COPY --from=react-ui-builder /app/dist ./core/http/react-ui/dist
# Generate protobuf Go code, build mock backend and UI test server
RUN make protogen-go && \
go build -o /out/mock-backend ./tests/e2e/mock-backend && \
go build -o /out/ui-test-server ./tests/e2e-ui
###################################
# Stage 3: Run Playwright tests
###################################
FROM mcr.microsoft.com/playwright:${PLAYWRIGHT_VERSION}-noble
# Install runtime dependency for the Go binary (opus codec support)
RUN apt-get update && \
apt-get install -y --no-install-recommends libopus0 && \
apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /work
# Copy test server binaries
COPY --from=go-builder /out/ui-test-server /usr/local/bin/ui-test-server
COPY --from=go-builder /out/mock-backend /usr/local/bin/mock-backend
# Install Playwright test dependencies
COPY core/http/react-ui/package.json core/http/react-ui/playwright.config.js ./
RUN npm install
# Copy test specs
COPY core/http/react-ui/e2e ./e2e
ENV CI=true
ENV PLAYWRIGHT_EXTERNAL_SERVER=1
CMD ["bash", "-c", "\
ui-test-server --mock-backend=/usr/local/bin/mock-backend --port=8089 & \
for i in $(seq 1 30); do \
curl -sf http://127.0.0.1:8089/readyz > /dev/null 2>&1 && break; \
sleep 1; \
done && \
npx playwright test; \
TEST_EXIT=$?; \
kill %1 2>/dev/null; \
exit $TEST_EXIT \
"]

136
tests/e2e-ui/main.go Normal file
View File

@@ -0,0 +1,136 @@
package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
httpapi "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/xlog"
"gopkg.in/yaml.v3"
)
func main() {
mockBackend := flag.String("mock-backend", "", "path to mock-backend binary")
port := flag.Int("port", 8089, "port to listen on")
flag.Parse()
if *mockBackend == "" {
fmt.Fprintln(os.Stderr, "error: --mock-backend is required")
os.Exit(1)
}
// Resolve to absolute path
absBackend, err := filepath.Abs(*mockBackend)
if err != nil {
fmt.Fprintf(os.Stderr, "error resolving mock-backend path: %v\n", err)
os.Exit(1)
}
if _, err := os.Stat(absBackend); err != nil {
fmt.Fprintf(os.Stderr, "mock-backend not found at %s: %v\n", absBackend, err)
os.Exit(1)
}
// Create temp dirs
tmpDir, err := os.MkdirTemp("", "ui-e2e-*")
if err != nil {
fmt.Fprintf(os.Stderr, "error creating temp dir: %v\n", err)
os.Exit(1)
}
defer os.RemoveAll(tmpDir)
modelsPath := filepath.Join(tmpDir, "models")
backendsPath := filepath.Join(tmpDir, "backends")
generatedDir := filepath.Join(tmpDir, "generated")
dataDir := filepath.Join(tmpDir, "data")
for _, d := range []string{modelsPath, backendsPath, generatedDir, dataDir} {
if err := os.MkdirAll(d, 0755); err != nil {
fmt.Fprintf(os.Stderr, "error creating dir %s: %v\n", d, err)
os.Exit(1)
}
}
// Write mock-model config
modelConfig := map[string]any{
"name": "mock-model",
"backend": "mock-backend",
"parameters": map[string]any{
"model": "mock-model.bin",
},
}
configYAML, err := yaml.Marshal(modelConfig)
if err != nil {
fmt.Fprintf(os.Stderr, "error marshaling config: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(filepath.Join(modelsPath, "mock-model.yaml"), configYAML, 0644); err != nil {
fmt.Fprintf(os.Stderr, "error writing config: %v\n", err)
os.Exit(1)
}
// Set up system state
systemState, err := system.GetSystemState(
system.WithModelPath(modelsPath),
system.WithBackendPath(backendsPath),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error getting system state: %v\n", err)
os.Exit(1)
}
// Create application
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app, err := application.New(
config.WithContext(ctx),
config.WithSystemState(systemState),
config.WithDebug(true),
config.WithDataPath(dataDir),
config.WithDynamicConfigDir(dataDir),
config.WithGeneratedContentDir(generatedDir),
config.EnableTracing,
)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating application: %v\n", err)
os.Exit(1)
}
// Register mock backend
app.ModelLoader().SetExternalBackend("mock-backend", absBackend)
// Create HTTP server
e, err := httpapi.API(app)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating HTTP API: %v\n", err)
os.Exit(1)
}
// Start server
addr := fmt.Sprintf("127.0.0.1:%d", *port)
go func() {
if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
xlog.Error("server error", "error", err)
os.Exit(1)
}
}()
fmt.Printf("UI test server listening on http://%s\n", addr)
// Wait for signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
fmt.Println("\nShutting down...")
cancel()
e.Close()
}