diff --git a/core/config/meta/registry.go b/core/config/meta/registry.go index 8cf4e4050..c98514a78 100644 --- a/core/config/meta/registry.go +++ b/core/config/meta/registry.go @@ -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": { diff --git a/core/config/meta/registry_test.go b/core/config/meta/registry_test.go index eff0cbf58..72b2380e5 100644 --- a/core/config/meta/registry_test.go +++ b/core/config/meta/registry_test.go @@ -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) diff --git a/core/http/react-ui/e2e/failover-editor.spec.js b/core/http/react-ui/e2e/failover-editor.spec.js new file mode 100644 index 000000000..da66227eb --- /dev/null +++ b/core/http/react-ui/e2e/failover-editor.spec.js @@ -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 }, + ]) + }) +}) diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 45668f96a..6ae1c22d5 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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); } /* =========================================================================== diff --git a/core/http/react-ui/src/components/ConfigFieldRenderer.jsx b/core/http/react-ui/src/components/ConfigFieldRenderer.jsx index 4e93e1e5a..26cdd8d01 100644 --- a/core/http/react-ui/src/components/ConfigFieldRenderer.jsx +++ b/core/http/react-ui/src/components/ConfigFieldRenderer.jsx @@ -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 ( +
+
+
+
+
{description}
+
+
+ +
+ ) + } + // PII detectors — a capability-filtered multi-select of token_classify // models (the consuming model's pii.detectors list). if (component === 'model-multi-select') { diff --git a/core/http/react-ui/src/components/FailoverTargetsEditor.jsx b/core/http/react-ui/src/components/FailoverTargetsEditor.jsx new file mode 100644 index 000000000..b52f276e8 --- /dev/null +++ b/core/http/react-ui/src/components/FailoverTargetsEditor.jsx @@ -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 ( +
+ {items.length === 0 && ( +
+ No targets yet. Add at least two — the first healthy one answers, the rest take over in order. +
+ )} + + {items.length === 1 && ( +
+ + Add at least one more target — a chain with a single target has nothing to fail over to. +
+ )} + + {items.map((row, i) => ( + update(i, mut)} + onRemove={() => remove(i)} + onMove={(dir) => move(i, dir)} + /> + ))} + + +
+ ) +} + +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 ( +
+
+ #{index + 1} + + + + {index === 0 ? 'served first' : 'fallback'} + +
+ +
+ onChange((r) => ({ ...r, model: v }))} + placeholder="target model..." + /> + + onChange((r) => ({ ...r, warm: v }))} /> + Warm + + +
+ + {error && ( +
+ + {error} +
+ )} +
+ ) +} + +// 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 +} diff --git a/core/http/react-ui/src/pages/ModelEditor.jsx b/core/http/react-ui/src/pages/ModelEditor.jsx index 81fce1b12..dc424414c 100644 --- a/core/http/react-ui/src/pages/ModelEditor.jsx +++ b/core/http/react-ui/src/pages/ModelEditor.jsx @@ -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 } diff --git a/core/http/react-ui/src/utils/modelTemplates.js b/core/http/react-ui/src/utils/modelTemplates.js index c3675f9db..321101a8f 100644 --- a/core/http/react-ui/src/utils/modelTemplates.js +++ b/core/http/react-ui/src/utils/modelTemplates.js @@ -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',