mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-30 01:54:31 -04:00
feat(ui): edit failover chain targets with a dedicated field
Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
d65b4d3e6a
commit
bdd4a73d05
8 files changed
+385
-3
No files matched your search
@@ -424,7 +424,7 @@ func DefaultRegistry() map[string]FieldMetaOverride {
|
||||
Section: "failover",
|
||||
Label: "Failover targets",
|
||||
Description: "Ordered list of models that serve this chain. The first healthy target serves each request; later targets take over when it fails. Mark a local target warm to keep it loaded.",
|
||||
Component: "json-editor",
|
||||
Component: "failover-targets",
|
||||
Order: 0,
|
||||
},
|
||||
"failover.probe.interval": {
|
||||
|
||||
@@ -33,6 +33,7 @@ var _ = Describe("alias field metadata", func() {
|
||||
reg := meta.DefaultRegistry()
|
||||
Expect(reg).To(HaveKey("failover.targets"))
|
||||
Expect(reg["failover.targets"].Section).To(Equal("failover"))
|
||||
Expect(reg["failover.targets"].Component).To(Equal("failover-targets"))
|
||||
var ids []string
|
||||
for _, s := range meta.DefaultSections() {
|
||||
ids = append(ids, s.ID)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Failover Chain template + FailoverTargetsEditor regression tests.
|
||||
//
|
||||
// A failover chain is a model config whose `failover.targets` field lists,
|
||||
// in order, the downstream models that answer for one name — the first
|
||||
// healthy target serves each request, the rest take over when it fails.
|
||||
// This covers:
|
||||
// - the create-flow template gallery exposes a "Failover Chain" card that
|
||||
// seeds a minimal name + two empty targets
|
||||
// - the dedicated FailoverTargetsEditor renders {model, warm} rows with
|
||||
// add/remove/move controls
|
||||
// - inline warnings for a single-target chain, a duplicated target model,
|
||||
// and a target that names the chain itself
|
||||
// - saving an edited chain sends the updated failover.targets array in
|
||||
// the PATCH body
|
||||
|
||||
const FAILOVER_METADATA = {
|
||||
sections: [
|
||||
{ id: 'general', label: 'General', icon: 'settings', order: 0 },
|
||||
{ id: 'failover', label: 'Failover', icon: 'shuffle', order: 90 },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
path: 'name', yaml_key: 'name', go_type: 'string', ui_type: 'string',
|
||||
section: 'general', label: 'Model Name', component: 'input', order: 0,
|
||||
},
|
||||
{
|
||||
path: 'failover.targets', yaml_key: 'targets', go_type: '[]FailoverTarget', ui_type: 'object',
|
||||
section: 'failover', label: 'Failover targets', component: 'failover-targets',
|
||||
description: 'Ordered list of models that serve this chain.',
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function mockCommon(page) {
|
||||
await page.route('**/api/auth/status', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ authEnabled: false, staticApiKeyRequired: false, providers: [] }) }))
|
||||
await page.route('**/api/models/config-metadata*', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(FAILOVER_METADATA) }))
|
||||
await page.route('**/api/models/config-metadata/autocomplete/**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ values: [] }) }))
|
||||
|
||||
page.on('pageerror', (err) => {
|
||||
throw new Error(`uncaught page error: ${err.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Failover Chain template - create flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockCommon(page)
|
||||
})
|
||||
|
||||
test('template gallery exposes the Failover Chain card', async ({ page }) => {
|
||||
await page.goto('/app/model-editor')
|
||||
await expect(page.getByRole('button', { name: /Failover Chain/i })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('failover template loads the editor with two target rows', async ({ page }) => {
|
||||
await page.goto('/app/model-editor?template=failover')
|
||||
await expect(page.getByText(/Unexpected Application Error/i)).toHaveCount(0)
|
||||
await expect(page.locator('h1.page-title')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByText('Failover targets').first()).toBeVisible()
|
||||
|
||||
// Two empty {model: ''} rows seeded by the template.
|
||||
await expect(page.locator('input[placeholder="target model..."]')).toHaveCount(2)
|
||||
await expect(page.getByRole('button', { name: /Add target/i }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Add target adds a row', async ({ page }) => {
|
||||
await page.goto('/app/model-editor?template=failover')
|
||||
await page.getByRole('button', { name: /Add target/i }).first().click()
|
||||
await expect(page.locator('input[placeholder="target model..."]')).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('removing a row removes it', async ({ page }) => {
|
||||
await page.goto('/app/model-editor?template=failover')
|
||||
await page.locator('button[title="Remove target"]').first().click()
|
||||
await expect(page.locator('input[placeholder="target model..."]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('move down/up reorders the target rows', async ({ page }) => {
|
||||
await page.goto('/app/model-editor?template=failover')
|
||||
const rows = page.locator('input[placeholder="target model..."]')
|
||||
await rows.nth(0).fill('model-a')
|
||||
await rows.nth(1).fill('model-b')
|
||||
|
||||
// Move the first row down — it should now be the second row's value.
|
||||
await page.locator('button[title="Move down"]').first().click()
|
||||
await expect(rows.nth(0)).toHaveValue('model-b')
|
||||
await expect(rows.nth(1)).toHaveValue('model-a')
|
||||
|
||||
// Move it back up with the second row's "Move up" control.
|
||||
await page.locator('button[title="Move up (tried earlier)"]').nth(1).click()
|
||||
await expect(rows.nth(0)).toHaveValue('model-a')
|
||||
await expect(rows.nth(1)).toHaveValue('model-b')
|
||||
})
|
||||
|
||||
test('a single remaining target shows a too-few warning', async ({ page }) => {
|
||||
await page.goto('/app/model-editor?template=failover')
|
||||
await page.locator('button[title="Remove target"]').first().click()
|
||||
await expect(page.locator('input[placeholder="target model..."]')).toHaveCount(1)
|
||||
await expect(page.getByText(/nothing to fail over to/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('duplicate target models flag both rows', async ({ page }) => {
|
||||
await page.goto('/app/model-editor?template=failover')
|
||||
const rows = page.locator('input[placeholder="target model..."]')
|
||||
await rows.nth(0).fill('same-model')
|
||||
await rows.nth(1).fill('same-model')
|
||||
await expect(page.getByText(/Duplicate target/i)).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('a target naming the chain itself is flagged', async ({ page }) => {
|
||||
await page.goto('/app/model-editor?template=failover')
|
||||
// Create mode renders the model name through a dedicated input (not the
|
||||
// generic field renderer), whose placeholder comes from the modelEditor
|
||||
// i18n namespace rather than the field registry.
|
||||
await page.locator('input[placeholder="my-model-name"]').fill('my-chain')
|
||||
const rows = page.locator('input[placeholder="target model..."]')
|
||||
await rows.nth(0).fill('my-chain')
|
||||
await expect(page.getByText(/chain's own name/i)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Failover Chain - saving an edited chain', () => {
|
||||
const MOCK_YAML = 'name: my-chain\nfailover:\n targets:\n - model: model-a\n warm: true\n - model: model-b\n warm: false\n'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockCommon(page)
|
||||
await page.route('**/api/models/edit/my-chain', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ config: MOCK_YAML, name: 'my-chain' }) }))
|
||||
// ModelFailoverStatus mounts unconditionally in edit mode; keep its
|
||||
// fetch + SSE subscription harmless for a chain the test doesn't care
|
||||
// about the live status of.
|
||||
await page.route('**/api/failover', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ chains: [] }) }))
|
||||
await page.route('**/api/failover/events', (route) =>
|
||||
route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, body: '' }))
|
||||
})
|
||||
|
||||
test('saving sends the updated failover.targets array in the PATCH body', async ({ page }) => {
|
||||
let patchBody = null
|
||||
await page.route('**/api/models/config-json/my-chain', (route) => {
|
||||
if (route.request().method() === 'PATCH') {
|
||||
patchBody = route.request().postDataJSON()
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, message: "Model 'my-chain' updated successfully" }) })
|
||||
} else {
|
||||
route.fulfill({ contentType: 'application/json', body: '{}' })
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto('/app/model-editor/my-chain')
|
||||
await expect(page.locator('h1', { hasText: 'Model Editor' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Existing targets loaded from YAML.
|
||||
const rows = page.locator('input[placeholder="target model..."]')
|
||||
await expect(rows).toHaveCount(2)
|
||||
|
||||
// Add a third target, then save.
|
||||
await page.getByRole('button', { name: /Add target/i }).first().click()
|
||||
await rows.nth(2).fill('model-c')
|
||||
|
||||
await page.locator('button', { hasText: 'Save Changes' }).click()
|
||||
await expect(page.locator('text=Configuration saved')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
expect(patchBody).toBeTruthy()
|
||||
expect(patchBody.failover.targets).toEqual([
|
||||
{ model: 'model-a', warm: true },
|
||||
{ model: 'model-b', warm: false },
|
||||
{ model: 'model-c', warm: false },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -12268,6 +12268,15 @@ button.collapsible-header:focus-visible {
|
||||
.cfr-num { width: 120px; font-size: var(--text-sm); }
|
||||
.cfr-select { width: 220px; font-size: var(--text-sm); }
|
||||
.cfr-radio { display: flex; align-items: center; gap: var(--spacing-sm); font-size: var(--text-sm); cursor: pointer; }
|
||||
|
||||
/* Failover targets editor — chain member list with a warm toggle and
|
||||
duplicate/self-reference warnings. */
|
||||
.fte-list { display: flex; flex-direction: column; gap: var(--spacing-sm); width: 100%; }
|
||||
.fte-empty { font-size: var(--text-sm); color: var(--color-text-muted); padding: var(--spacing-sm) 0; }
|
||||
.fte-warning { font-size: var(--text-xs); color: var(--color-warning); padding: var(--spacing-sm) 0; }
|
||||
.fte-row { padding: var(--spacing-sm); display: flex; flex-direction: column; gap: var(--spacing-xs); }
|
||||
.fte-row--error { border-color: var(--color-error); }
|
||||
.fte-row__head { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||
.pill-tiny { padding: 2px 6px; font-size: var(--text-xs); }
|
||||
|
||||
/* ===========================================================================
|
||||
|
||||
@@ -11,6 +11,7 @@ import PatternListEditor from './PatternListEditor'
|
||||
import ModelMultiSelect from './ModelMultiSelect'
|
||||
import RouterCandidatesEditor from './RouterCandidatesEditor'
|
||||
import RouterPoliciesEditor from './RouterPoliciesEditor'
|
||||
import FailoverTargetsEditor from './FailoverTargetsEditor'
|
||||
|
||||
// Map autocomplete provider to SearchableModelSelect capability
|
||||
const PROVIDER_TO_CAPABILITY = {
|
||||
@@ -381,6 +382,23 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
|
||||
)
|
||||
}
|
||||
|
||||
// Failover targets — ordered member list of a failover chain. Each row
|
||||
// is {model, warm}; duplicate/self-reference detection reads the edited
|
||||
// model's own name from FormContext.
|
||||
if (component === 'failover-targets') {
|
||||
return (
|
||||
<div className="list-row">
|
||||
<div className="hstack hstack--between mb-xs">
|
||||
<div>
|
||||
<div className="text-base fw-medium"><FieldLabel field={field} /></div>
|
||||
<div className="text-meta mt-xs">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<FailoverTargetsEditor value={value} onChange={handleChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// PII detectors — a capability-filtered multi-select of token_classify
|
||||
// models (the consuming model's pii.detectors list).
|
||||
if (component === 'model-multi-select') {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useFormContext } from '../contexts/FormContext'
|
||||
import SearchableModelSelect from './SearchableModelSelect'
|
||||
import Toggle from './Toggle'
|
||||
|
||||
// FailoverTargetsEditor renders the ordered member list of a failover
|
||||
// chain. Each row binds a downstream model plus a warm flag that keeps a
|
||||
// local target loaded rather than cold-started the first time it serves.
|
||||
//
|
||||
// Schema mirrors core/config.FailoverTarget:
|
||||
// { model: string, warm: bool }
|
||||
//
|
||||
// Three conditions make the chain useless even though the YAML stays
|
||||
// valid, so they are surfaced as inline warnings rather than blocked
|
||||
// outright (the admin may be mid-edit):
|
||||
// - fewer than two targets: nothing to fail over TO.
|
||||
// - the same model listed twice: the prober would track one backend
|
||||
// under two identities.
|
||||
// - a target equal to the chain's OWN name: the alias would point at
|
||||
// itself and never resolve.
|
||||
//
|
||||
// The model list from useModels() (what SearchableModelSelect searches)
|
||||
// does not carry each entry's backend, so a remote/proxy target can't be
|
||||
// told apart from a local one here to disable warm for it. The server
|
||||
// already rejects/warns on warm for a backend that can't be kept loaded,
|
||||
// so this editor leaves the toggle enabled for every row.
|
||||
export default function FailoverTargetsEditor({ value, onChange }) {
|
||||
const items = Array.isArray(value) ? value : []
|
||||
const ownName = useOwnModelName()
|
||||
|
||||
const duplicateModels = useMemo(() => {
|
||||
const seen = new Set()
|
||||
const dup = new Set()
|
||||
for (const it of items) {
|
||||
const model = it?.model
|
||||
if (!model) continue
|
||||
if (seen.has(model)) dup.add(model)
|
||||
else seen.add(model)
|
||||
}
|
||||
return dup
|
||||
}, [items])
|
||||
|
||||
const update = (index, mut) => {
|
||||
const next = items.map((it, i) => (i === index ? mut({ ...it }) : it))
|
||||
onChange(next)
|
||||
}
|
||||
const remove = (index) => onChange(items.filter((_, i) => i !== index))
|
||||
const move = (index, dir) => {
|
||||
const j = index + dir
|
||||
if (j < 0 || j >= items.length) return
|
||||
const next = items.slice()
|
||||
;[next[index], next[j]] = [next[j], next[index]]
|
||||
onChange(next)
|
||||
}
|
||||
const add = () => onChange([...items, { model: '', warm: false }])
|
||||
|
||||
return (
|
||||
<div className="fte-list">
|
||||
{items.length === 0 && (
|
||||
<div className="fte-empty">
|
||||
No targets yet. Add at least two — the first healthy one answers, the rest take over in order.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 1 && (
|
||||
<div className="fte-warning">
|
||||
<i className="fas fa-triangle-exclamation icon-before" />
|
||||
Add at least one more target — a chain with a single target has nothing to fail over to.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((row, i) => (
|
||||
<TargetRow
|
||||
key={i}
|
||||
index={i}
|
||||
total={items.length}
|
||||
row={row}
|
||||
duplicate={!!row?.model && duplicateModels.has(row.model)}
|
||||
isOwnName={!!row?.model && !!ownName && row.model === ownName}
|
||||
onChange={(mut) => update(i, mut)}
|
||||
onRemove={() => remove(i)}
|
||||
onMove={(dir) => move(i, dir)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm self-start"
|
||||
onClick={add}
|
||||
>
|
||||
<i className="fas fa-plus" /> Add target
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetRow({ index, total, row, duplicate, isOwnName, onChange, onRemove, onMove }) {
|
||||
const error = isOwnName
|
||||
? "This is the chain's own name — it would point at itself and never resolve."
|
||||
: duplicate
|
||||
? 'Duplicate target — the prober would track the same backend under two identities.'
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={`card fte-row${error ? ' fte-row--error' : ''}`}>
|
||||
<div className="hstack hstack--xs fte-row__head">
|
||||
<span className="fw-semibold">#{index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => onMove(-1)}
|
||||
disabled={index === 0}
|
||||
title="Move up (tried earlier)"
|
||||
>
|
||||
<i className="fas fa-arrow-up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => onMove(1)}
|
||||
disabled={index === total - 1}
|
||||
title="Move down"
|
||||
>
|
||||
<i className="fas fa-arrow-down" />
|
||||
</button>
|
||||
<span className="ml-auto text-meta">
|
||||
{index === 0 ? 'served first' : 'fallback'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hstack hstack--md fte-row__body">
|
||||
<SearchableModelSelect
|
||||
value={row?.model || ''}
|
||||
onChange={(v) => onChange((r) => ({ ...r, model: v }))}
|
||||
placeholder="target model..."
|
||||
/>
|
||||
<span className="hstack hstack--xs">
|
||||
<Toggle checked={!!row?.warm} onChange={(v) => onChange((r) => ({ ...r, warm: v }))} />
|
||||
<span className="text-meta">Warm</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={onRemove}
|
||||
title="Remove target"
|
||||
>
|
||||
<i className="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-error text-xs">
|
||||
<i className="fas fa-triangle-exclamation icon-before" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// useOwnModelName reads the edited model's own name from the surrounding
|
||||
// form so a target that points back at the chain itself is flagged.
|
||||
// Returns null without a FormContext (e.g. a preview render) or before a
|
||||
// name has been typed.
|
||||
function useOwnModelName() {
|
||||
const ctx = useFormContext()
|
||||
const name = ctx?.formData?.name
|
||||
return typeof name === 'string' && name.trim() ? name.trim() : null
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const SECTION_ICONS = {
|
||||
templates: 'fa-file-code', functions: 'fa-wrench', reasoning: 'fa-brain',
|
||||
diffusers: 'fa-image', tts: 'fa-volume-up', pipeline: 'fa-code-branch',
|
||||
grpc: 'fa-server', agent: 'fa-robot', mcp: 'fa-plug', router: 'fa-route', proxy: 'fa-cloud',
|
||||
mitm: 'fa-user-secret', pii: 'fa-user-shield', other: 'fa-ellipsis-h',
|
||||
mitm: 'fa-user-secret', pii: 'fa-user-shield', failover: 'fa-shuffle', other: 'fa-ellipsis-h',
|
||||
}
|
||||
|
||||
const SECTION_COLORS = {
|
||||
@@ -29,7 +29,7 @@ const SECTION_COLORS = {
|
||||
reasoning: 'var(--color-accent)', diffusers: 'var(--color-warning)', tts: 'var(--color-success)',
|
||||
pipeline: 'var(--color-accent)', grpc: 'var(--color-text-muted)', agent: 'var(--color-primary)',
|
||||
mcp: 'var(--color-accent)', router: 'var(--color-accent)', proxy: 'var(--color-info, var(--color-primary))',
|
||||
mitm: 'var(--color-warning)', pii: 'var(--color-error)', other: 'var(--color-text-muted)',
|
||||
mitm: 'var(--color-warning)', pii: 'var(--color-error)', failover: 'var(--color-accent)', other: 'var(--color-text-muted)',
|
||||
}
|
||||
|
||||
// flattenConfig turns a parsed YAML config into a flat { 'a.b.c': value }
|
||||
|
||||
+10
@@ -142,6 +142,16 @@ const MODEL_TEMPLATES = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'failover',
|
||||
label: 'Failover Chain',
|
||||
icon: 'fa-shuffle',
|
||||
description: 'Serve one model name from an ordered list of models. The first healthy one answers; the next takes over when it fails.',
|
||||
fields: {
|
||||
'name': '',
|
||||
'failover.targets': [{ model: '' }, { model: '' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'alias',
|
||||
label: 'Alias / Routing',
|
||||
|
||||
Reference in new issue
Block a user