From 7b11af7f7dae0bfbef235bc706ff90d9b8859f04 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 27 Sep 2026 01:27:42 +0000 Subject: [PATCH] feat(ui): list failover chains and badge chain models Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto --- .../react-ui/e2e/failover-overview.spec.js | 134 ++++++++++++++++++ .../e2e/nodes-fleet-dashboard.spec.js | 4 +- .../react-ui/public/locales/de/models.json | 19 ++- core/http/react-ui/public/locales/de/nav.json | 3 +- .../react-ui/public/locales/en/models.json | 19 ++- core/http/react-ui/public/locales/en/nav.json | 3 +- .../react-ui/public/locales/es/models.json | 19 ++- core/http/react-ui/public/locales/es/nav.json | 3 +- .../react-ui/public/locales/id/models.json | 19 ++- core/http/react-ui/public/locales/id/nav.json | 3 +- .../react-ui/public/locales/it/models.json | 19 ++- core/http/react-ui/public/locales/it/nav.json | 3 +- .../react-ui/public/locales/ko/models.json | 19 ++- core/http/react-ui/public/locales/ko/nav.json | 3 +- .../react-ui/public/locales/pt-BR/models.json | 19 ++- .../react-ui/public/locales/pt-BR/nav.json | 3 +- .../react-ui/public/locales/zh-CN/models.json | 19 ++- .../react-ui/public/locales/zh-CN/nav.json | 3 +- core/http/react-ui/src/App.css | 6 + .../src/components/FailoverChainStatus.jsx | 5 +- .../src/components/console/consoleConfig.js | 1 + core/http/react-ui/src/pages/Failover.jsx | 95 +++++++++++++ .../react-ui/src/pages/InstalledModels.jsx | 10 +- core/http/react-ui/src/router.jsx | 2 + 24 files changed, 412 insertions(+), 21 deletions(-) create mode 100644 core/http/react-ui/e2e/failover-overview.spec.js create mode 100644 core/http/react-ui/src/pages/Failover.jsx diff --git a/core/http/react-ui/e2e/failover-overview.spec.js b/core/http/react-ui/e2e/failover-overview.spec.js new file mode 100644 index 000000000..d9c1688c0 --- /dev/null +++ b/core/http/react-ui/e2e/failover-overview.spec.js @@ -0,0 +1,134 @@ +import { test, expect } from './coverage-fixtures.js' + +// Failover overview page (Operate -> Runtime -> Failover) and the matching +// "chain" badge on an installed model that is itself a failover chain. +// +// The overview is a dense table over GET /api/failover, kept live by the +// /api/failover/events SSE stream (same hook the Model Editor's chain strip +// uses), plus an empty state that points at the Failover Chain template. + +const NO_AUTH = { authEnabled: false, staticApiKeyRequired: false, providers: [] } + +const CHAIN_A = { + name: 'chain-a', + state: 'primary', + active: 'a', + active_since: '2026-09-26T09:00:00Z', + pinned: null, + targets: [ + { model: 'a', kind: 'local', warm: true, state: 'healthy', last_probe: '2026-09-26T09:59:00Z' }, + { model: 'b', kind: 'remote', warm: false, state: 'healthy' }, + ], +} + +const CHAIN_B = { + name: 'chain-b', + state: 'degraded', + active: 'x', + active_since: '2026-09-26T08:00:00Z', + pinned: null, + targets: [ + { model: 'x', kind: 'local', warm: true, state: 'down' }, + { model: 'y', kind: 'local', warm: false, state: 'healthy' }, + ], +} + +async function mockAuth(page, authStatus = NO_AUTH) { + await page.route('**/api/auth/status', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify(authStatus) })) +} + +async function mockFailover(page, chains) { + 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: `event: snapshot\ndata: ${JSON.stringify({ chains })}\n\n`, + })) +} + +test.describe('Failover overview', () => { + test.beforeEach(async ({ page }) => { + page.on('pageerror', (err) => { + throw new Error(`uncaught page error: ${err.message}`) + }) + }) + + test('admin sees the Failover entry in the Operate rail', async ({ page }) => { + await mockAuth(page) + await mockFailover(page, [CHAIN_A]) + await page.goto('/app/operate') + + const rail = page.locator('.console-layout > .console-rail') + const link = rail.locator('a.nav-item[href="/app/failover"]') + await expect(link).toBeVisible({ timeout: 10_000 }) + await expect(link).toContainText('Failover') + }) + + test('lists chains from GET /api/failover with their live health', async ({ page }) => { + await mockAuth(page) + await mockFailover(page, [CHAIN_A, CHAIN_B]) + await page.goto('/app/failover') + + const rows = page.locator('[data-testid="failover-overview"] tbody tr') + await expect(rows).toHaveCount(2) + + const first = rows.nth(0) + await expect(first.getByRole('link', { name: 'chain-a' })).toHaveAttribute('href', '/app/model-editor/chain-a') + await expect(first.locator('.status-pill').first()).toHaveText(/primary/i) + await expect(first).toContainText('a') + await expect(first.locator('.failover-overview__targets .status-pill')).toHaveCount(2) + + const second = rows.nth(1) + await expect(second.getByRole('link', { name: 'chain-b' })).toHaveAttribute('href', '/app/model-editor/chain-b') + await expect(second.locator('.status-pill').first()).toHaveText(/degraded/i) + }) + + test('follows target.state over SSE without a page reload', async ({ page }) => { + await mockAuth(page) + await page.route('**/api/failover', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify({ chains: [CHAIN_A] }) })) + const sseBody = + `event: snapshot\ndata: ${JSON.stringify({ chains: [CHAIN_A] })}\n\n` + + 'event: target.state\ndata: {"type":"target.state","chain":"chain-a","target":"b","from":"healthy","to":"down","error":"dial tcp: refused"}\n\n' + await page.route('**/api/failover/events', (route) => + route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, body: sseBody })) + await page.goto('/app/failover') + + const row = page.locator('[data-testid="failover-overview"] tbody tr').first() + await expect(row).toBeVisible({ timeout: 10_000 }) + const targetB = row.locator('.failover-overview__targets .status-pill', { hasText: 'b' }) + await expect(targetB).toHaveClass(/status-pill--error/) + }) + + test('shows an empty state pointing at the Failover Chain template', async ({ page }) => { + await mockAuth(page) + await mockFailover(page, []) + await page.goto('/app/failover') + + await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10_000 }) + const cta = page.getByRole('link', { name: /create a failover chain/i }) + await expect(cta).toHaveAttribute('href', '/app/model-editor?template=failover') + }) +}) + +test.describe('Installed Models - chain badge', () => { + test.beforeEach(async ({ page }) => { + await mockAuth(page) + await page.route('**/api/models/capabilities', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify({ data: [ + { id: 'chain-a', capabilities: ['chat'], backend: 'llama-cpp' }, + ] }) })) + await page.route('**/api/aliases', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify([]) })) + await mockFailover(page, [CHAIN_A]) + }) + + test('renders a read-only chain -> target badge on a chain model', async ({ page }) => { + await page.goto('/app/models?view=installed') + await page.locator('[data-entity="chain-a"]').click() + await expect(page.getByText('chain → a')).toBeVisible({ timeout: 10_000 }) + }) +}) diff --git a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js index e7b0ef6e0..b572af619 100644 --- a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js +++ b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js @@ -47,7 +47,7 @@ test.describe('Nodes fleet dashboard', () => { const rail = page.locator('.console-layout > .console-rail') await expect(rail).toBeVisible() - await expect(rail.locator('a.nav-item')).toHaveCount(13) + await expect(rail.locator('a.nav-item')).toHaveCount(14) await expect(rail.locator('a[href="/app/nodes"]')).toHaveClass(/active/) await expect(rail.locator('a[href$="/swagger/index.html"]')).toHaveAttribute('target', '_blank') }) @@ -67,7 +67,7 @@ test.describe('Nodes fleet dashboard', () => { await expect(rail.locator('.console-rail-groups')).toBeHidden() await rail.getByRole('button', { name: 'Expand Operate navigation' }).click() await expect(rail.locator('.console-rail-groups')).toBeVisible() - await expect(rail.locator('a.nav-item')).toHaveCount(13) + await expect(rail.locator('a.nav-item')).toHaveCount(14) }) test('shows aggregate health, capacity, attention filtering, search, sorting, and grouping', async ({ page }) => { diff --git a/core/http/react-ui/public/locales/de/models.json b/core/http/react-ui/public/locales/de/models.json index 8ac715477..d88cb8c70 100644 --- a/core/http/react-ui/public/locales/de/models.json +++ b/core/http/react-ui/public/locales/de/models.json @@ -40,7 +40,8 @@ "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", - "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}" + "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", + "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" }, "open": { "title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS", @@ -269,6 +270,22 @@ "errors": { "pin": "Fixieren fehlgeschlagen: {{error}}", "unpin": "Lösen fehlgeschlagen: {{error}}" + }, + "overview": { + "title": "Failover-Ketten", + "subtitle": "Live-Zustand und aktives Ziel für jede Failover-Kette.", + "columns": { + "chain": "Kette", + "state": "Status", + "active": "Aktives Ziel", + "targets": "Ziele", + "changed": "Geändert" + }, + "empty": { + "title": "Noch keine Failover-Ketten", + "text": "Fügen Sie eine Failover-Kette hinzu, um fehlerhafte Ziele automatisch zu umgehen.", + "cta": "Failover-Kette erstellen" + } } } } diff --git a/core/http/react-ui/public/locales/de/nav.json b/core/http/react-ui/public/locales/de/nav.json index e881b3cad..dea1d5f80 100644 --- a/core/http/react-ui/public/locales/de/nav.json +++ b/core/http/react-ui/public/locales/de/nav.json @@ -60,7 +60,8 @@ "middleware": "Middleware", "activity": "Aktivität", "overview": "Übersicht", - "thisMachine": "Dieser Rechner" + "thisMachine": "Dieser Rechner", + "failover": "Failover" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json index 72e841600..a2150e785 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -40,7 +40,8 @@ "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", - "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}" + "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", + "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" }, "open": { "title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS", @@ -286,6 +287,22 @@ "errors": { "pin": "Pin failed: {{error}}", "unpin": "Unpin failed: {{error}}" + }, + "overview": { + "title": "Failover chains", + "subtitle": "Live health and the active target for every failover chain.", + "columns": { + "chain": "Chain", + "state": "State", + "active": "Active target", + "targets": "Targets", + "changed": "Changed" + }, + "empty": { + "title": "No failover chains yet", + "text": "Add a failover chain to route around unhealthy targets automatically.", + "cta": "Create a failover chain" + } } } } diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index ac55dd533..6d4af666a 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -61,7 +61,8 @@ "api": "API", "activity": "Activity", "overview": "Overview", - "thisMachine": "This machine" + "thisMachine": "This machine", + "failover": "Failover" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/es/models.json b/core/http/react-ui/public/locales/es/models.json index ed22edb98..989189850 100644 --- a/core/http/react-ui/public/locales/es/models.json +++ b/core/http/react-ui/public/locales/es/models.json @@ -40,7 +40,8 @@ "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", - "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}" + "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", + "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" }, "open": { "title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS", @@ -269,6 +270,22 @@ "errors": { "pin": "No se pudo fijar: {{error}}", "unpin": "No se pudo liberar: {{error}}" + }, + "overview": { + "title": "Cadenas de failover", + "subtitle": "Estado en vivo y destino activo de cada cadena de failover.", + "columns": { + "chain": "Cadena", + "state": "Estado", + "active": "Destino activo", + "targets": "Destinos", + "changed": "Cambiado" + }, + "empty": { + "title": "Aún no hay cadenas de failover", + "text": "Agrega una cadena de failover para evitar automáticamente los destinos en mal estado.", + "cta": "Crear una cadena de failover" + } } } } diff --git a/core/http/react-ui/public/locales/es/nav.json b/core/http/react-ui/public/locales/es/nav.json index 8976ff270..333fd4919 100644 --- a/core/http/react-ui/public/locales/es/nav.json +++ b/core/http/react-ui/public/locales/es/nav.json @@ -60,7 +60,8 @@ "middleware": "Middleware", "activity": "Actividad", "overview": "Resumen", - "thisMachine": "Esta máquina" + "thisMachine": "Esta máquina", + "failover": "Failover" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/id/models.json b/core/http/react-ui/public/locales/id/models.json index 812fa755f..1dee74031 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -40,7 +40,8 @@ "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", - "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}" + "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", + "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" }, "open": { "title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS", @@ -282,6 +283,22 @@ "errors": { "pin": "Gagal menyematkan: {{error}}", "unpin": "Gagal melepas: {{error}}" + }, + "overview": { + "title": "Rantai failover", + "subtitle": "Kesehatan langsung dan target aktif untuk setiap rantai failover.", + "columns": { + "chain": "Rantai", + "state": "Status", + "active": "Target aktif", + "targets": "Target", + "changed": "Berubah" + }, + "empty": { + "title": "Belum ada rantai failover", + "text": "Tambahkan rantai failover untuk melewati target yang tidak sehat secara otomatis.", + "cta": "Buat rantai failover" + } } } } diff --git a/core/http/react-ui/public/locales/id/nav.json b/core/http/react-ui/public/locales/id/nav.json index ab8f8735d..90d1a2453 100644 --- a/core/http/react-ui/public/locales/id/nav.json +++ b/core/http/react-ui/public/locales/id/nav.json @@ -61,7 +61,8 @@ "api": "API", "activity": "Aktivitas", "overview": "Ikhtisar", - "thisMachine": "Mesin ini" + "thisMachine": "Mesin ini", + "failover": "Failover" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/it/models.json b/core/http/react-ui/public/locales/it/models.json index d02010234..edcc1b587 100644 --- a/core/http/react-ui/public/locales/it/models.json +++ b/core/http/react-ui/public/locales/it/models.json @@ -40,7 +40,8 @@ "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", - "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}" + "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", + "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" }, "open": { "title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS", @@ -269,6 +270,22 @@ "errors": { "pin": "Fissaggio non riuscito: {{error}}", "unpin": "Sblocco non riuscito: {{error}}" + }, + "overview": { + "title": "Catene di failover", + "subtitle": "Stato in tempo reale e destinazione attiva per ogni catena di failover.", + "columns": { + "chain": "Catena", + "state": "Stato", + "active": "Destinazione attiva", + "targets": "Destinazioni", + "changed": "Cambiato" + }, + "empty": { + "title": "Nessuna catena di failover", + "text": "Aggiungi una catena di failover per aggirare automaticamente le destinazioni non integre.", + "cta": "Crea una catena di failover" + } } } } diff --git a/core/http/react-ui/public/locales/it/nav.json b/core/http/react-ui/public/locales/it/nav.json index 74ff7c99d..0f819b039 100644 --- a/core/http/react-ui/public/locales/it/nav.json +++ b/core/http/react-ui/public/locales/it/nav.json @@ -60,7 +60,8 @@ "middleware": "Middleware", "activity": "Attività", "overview": "Panoramica", - "thisMachine": "Questa macchina" + "thisMachine": "Questa macchina", + "failover": "Failover" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/ko/models.json b/core/http/react-ui/public/locales/ko/models.json index 20741f534..b2a20016e 100644 --- a/core/http/react-ui/public/locales/ko/models.json +++ b/core/http/react-ui/public/locales/ko/models.json @@ -40,7 +40,8 @@ "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", - "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}" + "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", + "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" }, "open": { "title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS", @@ -253,6 +254,22 @@ "errors": { "pin": "고정 실패: {{error}}", "unpin": "고정 해제 실패: {{error}}" + }, + "overview": { + "title": "페일오버 체인", + "subtitle": "모든 페일오버 체인의 실시간 상태와 활성 대상입니다.", + "columns": { + "chain": "체인", + "state": "상태", + "active": "활성 대상", + "targets": "대상", + "changed": "변경됨" + }, + "empty": { + "title": "아직 페일오버 체인이 없습니다", + "text": "상태가 나빌 대상을 자동으로 우회하려면 페일오버 체인을 추가하세요.", + "cta": "페일오버 체인 만들기" + } } } } diff --git a/core/http/react-ui/public/locales/ko/nav.json b/core/http/react-ui/public/locales/ko/nav.json index 16d40a299..739a53088 100644 --- a/core/http/react-ui/public/locales/ko/nav.json +++ b/core/http/react-ui/public/locales/ko/nav.json @@ -60,7 +60,8 @@ "api": "API", "activity": "활동", "overview": "개요", - "thisMachine": "이 머신" + "thisMachine": "이 머신", + "failover": "페일오버" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/pt-BR/models.json b/core/http/react-ui/public/locales/pt-BR/models.json index 1c4fa1067..26e567a44 100644 --- a/core/http/react-ui/public/locales/pt-BR/models.json +++ b/core/http/react-ui/public/locales/pt-BR/models.json @@ -40,7 +40,8 @@ "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", - "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}" + "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", + "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" }, "open": { "title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS", @@ -285,6 +286,22 @@ "errors": { "pin": "Falha ao fixar: {{error}}", "unpin": "Falha ao desafixar: {{error}}" + }, + "overview": { + "title": "Cadeias de failover", + "subtitle": "Saúde em tempo real e destino ativo de cada cadeia de failover.", + "columns": { + "chain": "Cadeia", + "state": "Estado", + "active": "Destino ativo", + "targets": "Destinos", + "changed": "Alterado" + }, + "empty": { + "title": "Ainda não há cadeias de failover", + "text": "Adicione uma cadeia de failover para contornar automaticamente destinos com problemas.", + "cta": "Criar uma cadeia de failover" + } } } } diff --git a/core/http/react-ui/public/locales/pt-BR/nav.json b/core/http/react-ui/public/locales/pt-BR/nav.json index f0164bf0b..2d53ff717 100644 --- a/core/http/react-ui/public/locales/pt-BR/nav.json +++ b/core/http/react-ui/public/locales/pt-BR/nav.json @@ -61,7 +61,8 @@ "api": "API", "activity": "Atividade", "overview": "Visão geral", - "thisMachine": "Esta máquina" + "thisMachine": "Esta máquina", + "failover": "Failover" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/zh-CN/models.json b/core/http/react-ui/public/locales/zh-CN/models.json index 3a2a0aa9c..40f78260e 100644 --- a/core/http/react-ui/public/locales/zh-CN/models.json +++ b/core/http/react-ui/public/locales/zh-CN/models.json @@ -40,7 +40,8 @@ "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", - "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}" + "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", + "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" }, "open": { "title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS", @@ -269,6 +270,22 @@ "errors": { "pin": "固定失败:{{error}}", "unpin": "取消固定失败:{{error}}" + }, + "overview": { + "title": "故障转移链", + "subtitle": "每条故障转移链的实时健康状态和当前目标。", + "columns": { + "chain": "链", + "state": "状态", + "active": "当前目标", + "targets": "目标", + "changed": "变更时间" + }, + "empty": { + "title": "尚无故障转移链", + "text": "添加故障转移链以自动绕过不健康的目标。", + "cta": "创建故障转移链" + } } } } diff --git a/core/http/react-ui/public/locales/zh-CN/nav.json b/core/http/react-ui/public/locales/zh-CN/nav.json index 6e88ead91..d3dd01d04 100644 --- a/core/http/react-ui/public/locales/zh-CN/nav.json +++ b/core/http/react-ui/public/locales/zh-CN/nav.json @@ -60,7 +60,8 @@ "middleware": "Middleware", "activity": "活动", "overview": "概览", - "thisMachine": "本机" + "thisMachine": "本机", + "failover": "故障转移" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 6ae1c22d5..448bb6d7f 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -12399,6 +12399,12 @@ button.collapsible-header:focus-visible { color: var(--color-error); } .failover-status__action { text-align: right; } + +/* Failover overview (Operate -> Runtime): one row per chain, with a compact + strip of per-target pills standing in for the detailed table the Model + Editor shows for a single chain. */ +.failover-overview__targets { display: inline-flex; flex-wrap: wrap; gap: 4px; } +.failover-overview__target-pill { font-size: 0.75rem; } .me-tabs { display: flex; gap: 0; padding: 0 var(--spacing-lg); border-bottom: 1px solid var(--color-border); } .me-tab { padding: var(--spacing-sm) var(--spacing-md); diff --git a/core/http/react-ui/src/components/FailoverChainStatus.jsx b/core/http/react-ui/src/components/FailoverChainStatus.jsx index c5afcd944..be1579218 100644 --- a/core/http/react-ui/src/components/FailoverChainStatus.jsx +++ b/core/http/react-ui/src/components/FailoverChainStatus.jsx @@ -13,8 +13,9 @@ const UNITS = [ ] // Localized "5 minutes ago" from an RFC 3339 timestamp. Intl keeps the phrase -// in the viewer's language without a translation key per unit. -function relative(ts, lng) { +// in the viewer's language without a translation key per unit. Exported so +// other failover surfaces (the overview table) share the same phrasing. +export function relative(ts, lng) { const ms = Date.parse(ts) if (!ms) return null const seconds = Math.round((ms - Date.now()) / 1000) diff --git a/core/http/react-ui/src/components/console/consoleConfig.js b/core/http/react-ui/src/components/console/consoleConfig.js index 14bb43c83..d910c6e6b 100644 --- a/core/http/react-ui/src/components/console/consoleConfig.js +++ b/core/http/react-ui/src/components/console/consoleConfig.js @@ -74,6 +74,7 @@ export const operateConsole = { { path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true, signal: 'backends' }, { path: '/app/voice-library', icon: 'fas fa-wave-square', labelKey: 'items.voiceLibrary', adminOnly: true }, { path: '/app/activity', icon: 'fas fa-download', labelKey: 'items.activity', adminOnly: true, badge: 'operations', signal: 'activity' }, + { path: '/app/failover', icon: 'fas fa-shuffle', labelKey: 'items.failover', adminOnly: true }, ], }, { diff --git a/core/http/react-ui/src/pages/Failover.jsx b/core/http/react-ui/src/pages/Failover.jsx new file mode 100644 index 000000000..39fdffc26 --- /dev/null +++ b/core/http/react-ui/src/pages/Failover.jsx @@ -0,0 +1,95 @@ +import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import PageHeader from '../components/PageHeader' +import LoadingSpinner from '../components/LoadingSpinner' +import ResponsiveTable from '../components/ResponsiveTable' +import StatusPill from '../components/StatusPill' +import useFailoverChains from '../hooks/useFailoverChains' +import { relative } from '../components/FailoverChainStatus' + +// Failover: the Operate -> Runtime overview of every failover chain configured +// on this profile. One row per chain: its live state, the target actually +// serving it right now, a compact per-target health strip, and how long the +// active target has held the chain. Sourced from the same hook (GET +// /api/failover, kept live over the /api/failover/events SSE stream) that +// drives the Model Editor's chain strip, so both surfaces always agree. +export default function Failover() { + const { t, i18n } = useTranslation('models') + const { chains, loading, error } = useFailoverChains() + + return ( +
+ + + {error && ( +
+ {error} +
+ )} + + {loading ? ( + + ) : chains.length === 0 ? ( +
+
+

{t('failover.overview.empty.title')}

+

{t('failover.overview.empty.text')}

+ + {t('failover.overview.empty.cta')} + +
+ ) : ( + + + + {t('failover.overview.columns.chain')} + {t('failover.overview.columns.state')} + {t('failover.overview.columns.active')} + {t('failover.overview.columns.targets')} + {t('failover.overview.columns.changed')} + + + + {chains.map(chain => { + const since = relative(chain.active_since, i18n.language) + return ( + + + + {chain.name} + + + + + + {chain.active} + + + {(chain.targets || []).map(target => ( + + ))} + + + + {since || '-'} + + + ) + })} + + + )} +
+ ) +} diff --git a/core/http/react-ui/src/pages/InstalledModels.jsx b/core/http/react-ui/src/pages/InstalledModels.jsx index e3c40ee4c..6013d984f 100644 --- a/core/http/react-ui/src/pages/InstalledModels.jsx +++ b/core/http/react-ui/src/pages/InstalledModels.jsx @@ -14,6 +14,7 @@ import StatGrid from '../components/split/StatGrid' import { useModels } from '../hooks/useModels' import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment' import { useOperations } from '../hooks/useOperations' +import useFailoverChains from '../hooks/useFailoverChains' import { backendControlApi, modelsApi, nodesApi, systemApi } from '../utils/api' import { renderMarkdown, stripMarkdown } from '../utils/markdown' import { safeHref } from '../utils/url' @@ -106,6 +107,7 @@ export default function InstalledModels({ const { models, loading, error: loadError, refetch } = useModels() const { enrichModel } = useGalleryEnrichment() const { operations } = useOperations() + const { byName: failoverChains } = useFailoverChains() const [loadedModelIds, setLoadedModelIds] = useState(() => new Set()) const [aliasTargets, setAliasTargets] = useState({}) const [distributedMode, setDistributedMode] = useState(false) @@ -337,6 +339,7 @@ export default function InstalledModels({ const enriched = enrichModel(selectedModel.id) const useCases = modelUseCases(selectedModel) const running = isRunning(selectedModel) + const chain = failoverChains[selectedModel.id] const pending = pendingActions.has(selectedModel.id) return ( )} > - {(aliasTargets[selectedModel.id] || selectedModel.source === 'registry-only') && ( + {(aliasTargets[selectedModel.id] || selectedModel.source === 'registry-only' || chain) && (
{selectedModel.source === 'registry-only' && ( @@ -433,6 +436,11 @@ export default function InstalledModels({ {t('lifecycle.detail.alias', { target: aliasTargets[selectedModel.id] })} )} + {chain && ( + + {t('lifecycle.detail.chain', { target: chain.active })} + + )}
)} diff --git a/core/http/react-ui/src/router.jsx b/core/http/react-ui/src/router.jsx index 136e0ddf6..c871e3b73 100644 --- a/core/http/react-ui/src/router.jsx +++ b/core/http/react-ui/src/router.jsx @@ -131,6 +131,7 @@ const NotFound = page(null, () => import('./pages/NotFound')) const Usage = page('usage', () => import('./pages/Usage')) const Users = page('users', () => import('./pages/Users')) const Middleware = page('middleware', () => import('./pages/Middleware')) +const Failover = page('failover', () => import('./pages/Failover')) const Account = page('account', () => import('./pages/Account')) import ConsoleLayout from './components/console/ConsoleLayout' @@ -223,6 +224,7 @@ const appChildren = [ { path: 'usage', element: }, { path: 'users', element: }, { path: 'middleware', element: }, + { path: 'failover', element: }, ], },