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:
Ettore Di Giacinto committed 2026-09-27 07:42:21 +00:00
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",
+6
View File
@@ -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 },
],
},
{
+95
View File
@@ -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>
)}
+2
View File
@@ -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> },
],
},