mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-29 09:35:02 -04:00
feat(ui): list failover chains and badge chain models
Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
bdd4a73d05
commit
7b11af7f7d
24 files changed
+412
-21
No files matched your search
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@
|
||||
"middleware": "Middleware",
|
||||
"activity": "Aktivität",
|
||||
"overview": "Übersicht",
|
||||
"thisMachine": "Dieser Rechner"
|
||||
"thisMachine": "Dieser Rechner",
|
||||
"failover": "Failover"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,8 @@
|
||||
"api": "API",
|
||||
"activity": "Activity",
|
||||
"overview": "Overview",
|
||||
"thisMachine": "This machine"
|
||||
"thisMachine": "This machine",
|
||||
"failover": "Failover"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@
|
||||
"middleware": "Middleware",
|
||||
"activity": "Actividad",
|
||||
"overview": "Resumen",
|
||||
"thisMachine": "Esta máquina"
|
||||
"thisMachine": "Esta máquina",
|
||||
"failover": "Failover"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,8 @@
|
||||
"api": "API",
|
||||
"activity": "Aktivitas",
|
||||
"overview": "Ikhtisar",
|
||||
"thisMachine": "Mesin ini"
|
||||
"thisMachine": "Mesin ini",
|
||||
"failover": "Failover"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@
|
||||
"middleware": "Middleware",
|
||||
"activity": "Attività",
|
||||
"overview": "Panoramica",
|
||||
"thisMachine": "Questa macchina"
|
||||
"thisMachine": "Questa macchina",
|
||||
"failover": "Failover"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -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": "페일오버 체인 만들기"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@
|
||||
"api": "API",
|
||||
"activity": "활동",
|
||||
"overview": "개요",
|
||||
"thisMachine": "이 머신"
|
||||
"thisMachine": "이 머신",
|
||||
"failover": "페일오버"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "创建故障转移链"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@
|
||||
"middleware": "Middleware",
|
||||
"activity": "活动",
|
||||
"overview": "概览",
|
||||
"thisMachine": "本机"
|
||||
"thisMachine": "本机",
|
||||
"failover": "故障转移"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<div className="page-pad" data-testid="failover-overview">
|
||||
<PageHeader
|
||||
title={t('failover.overview.title')}
|
||||
supporting={t('failover.overview.subtitle')}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="attention-callout attention-callout--error" role="alert">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : chains.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-shuffle" /></div>
|
||||
<h2 className="empty-state-title">{t('failover.overview.empty.title')}</h2>
|
||||
<p className="empty-state-text">{t('failover.overview.empty.text')}</p>
|
||||
<Link className="btn btn-primary btn-sm" to="/app/model-editor?template=failover">
|
||||
{t('failover.overview.empty.cta')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('failover.overview.columns.chain')}</th>
|
||||
<th>{t('failover.overview.columns.state')}</th>
|
||||
<th>{t('failover.overview.columns.active')}</th>
|
||||
<th>{t('failover.overview.columns.targets')}</th>
|
||||
<th>{t('failover.overview.columns.changed')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{chains.map(chain => {
|
||||
const since = relative(chain.active_since, i18n.language)
|
||||
return (
|
||||
<tr key={chain.name}>
|
||||
<td>
|
||||
<Link to={`/app/model-editor/${encodeURIComponent(chain.name)}`}>
|
||||
<code>{chain.name}</code>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<StatusPill status={chain.state} label={t(`failover.states.${chain.state}`, chain.state)} />
|
||||
</td>
|
||||
<td><code>{chain.active}</code></td>
|
||||
<td>
|
||||
<span className="failover-overview__targets">
|
||||
{(chain.targets || []).map(target => (
|
||||
<StatusPill
|
||||
key={target.model}
|
||||
status={target.state}
|
||||
label={target.model}
|
||||
className="failover-overview__target-pill"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="text-muted"
|
||||
title={chain.active_since ? new Date(chain.active_since).toLocaleString(i18n.language) : undefined}
|
||||
>
|
||||
{since || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</ResponsiveTable>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<ModelLifecycleDetailShell
|
||||
@@ -421,7 +424,7 @@ export default function InstalledModels({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{(aliasTargets[selectedModel.id] || selectedModel.source === 'registry-only') && (
|
||||
{(aliasTargets[selectedModel.id] || selectedModel.source === 'registry-only' || chain) && (
|
||||
<div className="badge-row">
|
||||
{selectedModel.source === 'registry-only' && (
|
||||
<span className="badge badge-warning" title={t('lifecycle.detail.adoptedHint')}>
|
||||
@@ -433,6 +436,11 @@ export default function InstalledModels({
|
||||
<i className="fas fa-arrow-right-arrow-left" /> {t('lifecycle.detail.alias', { target: aliasTargets[selectedModel.id] })}
|
||||
</span>
|
||||
)}
|
||||
{chain && (
|
||||
<span className="badge badge-info" title={t('lifecycle.detail.chainTitle', { target: chain.active })}>
|
||||
<i className="fas fa-shuffle" /> {t('lifecycle.detail.chain', { target: chain.active })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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: <Usage /> },
|
||||
{ path: 'users', element: <RequireAuthEnabled><Admin><Users /></Admin></RequireAuthEnabled> },
|
||||
{ path: 'middleware', element: <Admin><Middleware /></Admin> },
|
||||
{ path: 'failover', element: <Admin><Failover /></Admin> },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user