mirror of
https://github.com/mudler/LocalAI.git
synced 2026-03-31 13:15:51 -04:00
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:
committed by
GitHub
parent
d8161bfe57
commit
3d9ccd1ddc
72
.github/workflows/tests-ui-e2e.yml
vendored
Normal file
72
.github/workflows/tests-ui-e2e.yml
vendored
Normal 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
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
17
Makefile
17
Makefile
@@ -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
|
||||
########################################################
|
||||
|
||||
23
core/http/react-ui/e2e/navigation.spec.js
Normal file
23
core/http/react-ui/e2e/navigation.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
96
core/http/react-ui/e2e/traces.spec.js
Normal file
96
core/http/react-ui/e2e/traces.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
64
core/http/react-ui/package-lock.json
generated
64
core/http/react-ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
24
core/http/react-ui/playwright.config.js
Normal file
24
core/http/react-ui/playwright.config.js
Normal 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,
|
||||
},
|
||||
})
|
||||
20
core/http/react-ui/src/components/Modal.jsx
Normal file
20
core/http/react-ui/src/components/Modal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
core/http/react-ui/src/components/SettingRow.jsx
Normal file
15
core/http/react-ui/src/components/SettingRow.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
core/http/react-ui/src/components/Toggle.jsx
Normal file
28
core/http/react-ui/src/components/Toggle.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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)',
|
||||
}}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)' },
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
1
tests/e2e-ui/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ui-test-server
|
||||
92
tests/e2e-ui/Dockerfile
Normal file
92
tests/e2e-ui/Dockerfile
Normal 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
136
tests/e2e-ui/main.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user