feat(ui): unify model and backend lifecycle (#11548)

* feat(ui): add installed model lifecycle

Models now owns catalog exploration and installed runtime controls under one canonical route. URL-owned state keeps lifecycle context recoverable through links and browser history.

Assisted-by: Codex:gpt-5 Playwright

* feat(ui): add installed backend lifecycle

Backends split discovery from backend-binary management. The canonical
page now keeps both lifecycle views under one URL-backed shell while it
preserves target-node placement.

Assisted-by: Codex:gpt-5 Playwright

* fix(ui): repair lifecycle state updates

Installed models lost distributed refreshes and kept a deleted selection. Backend searches also stopped tracking URL changes, while batch upgrades stopped after their first error.

Preserve background refreshes and finish each requested batch action. Drive catalog results from URL-backed state without losing full metadata.

Assisted-by: Codex:gpt-5 [Playwright]

* feat(ui): make resource pages canonical

Replace Host navigation with canonical Models and Backends lifecycle routes, preserve legacy management URLs, and surface shared host capacity on the Operate overview.

Assisted-by: Codex:gpt-5 [Playwright]

* feat(ui): complete canonical resource lifecycle

Finish the responsive list-to-detail behavior, remove the retired Host implementation, and keep Explore focused on discovery while Installed owns destructive actions. Update regression coverage, localization, documentation, and development binding for the canonical resource pages.

Assisted-by: Codex:gpt-5 [Playwright]

* docs(ui): record the UI design context

Record the approved users, brand character, and design principles so
future interface work uses the same product direction. Index the context
from the repository's agent instructions.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler's LocalAI [bot]
2026-08-16 11:57:31 +02:00
committed by GitHub
parent 6fb9ab38aa
commit 0aaff91ebd
88 changed files with 3671 additions and 2084 deletions
+21
View File
@@ -0,0 +1,21 @@
## Design Context
### Users
LocalAI serves both single-host users who want to install and try models quickly and experienced developers, ML engineers, system administrators, and DevOps operators who manage production hosts or distributed clusters. The interface must support first-time discovery without hiding the runtime state, configuration, and control that returning operators need.
### Brand Personality
Capable, easy to use, and trustworthy. The interface should make sophisticated local-AI infrastructure feel understandable and under control. It should be direct and calm rather than playful, ornamental, or intimidating.
### Aesthetic Direction
Use LocalAI's established technical, editorial design language: Geist typography, compact information density, sharp geometry, deep blue-black surfaces, action blue, mint for healthy/local/live state, and amber only for decisions requiring attention. Support both dark and light themes. Avoid generic card dashboards, decorative gradients, glass effects, and visual noise.
### Design Principles
1. Use progressive disclosure to serve newcomers and operators in the same workflow: make the common path obvious, then reveal operational depth in context.
2. Organize navigation around user intent and lifecycle state, not implementation concepts or nested containers.
3. Give each resource one canonical home; expose discovery, installed state, and runtime state as clear views of that resource instead of duplicating management surfaces.
4. Keep operational status visible and trustworthy through precise labels, explicit scope, and actionable state—not decoration.
5. Preserve information density for expert use while flattening navigation and reducing repeated summaries, tabs, rails, and panels.
+1
View File
@@ -33,6 +33,7 @@ LocalAI follows the Linux kernel project's [guidelines for AI coding assistants]
| [.agents/localai-assistant-mcp.md](.agents/localai-assistant-mcp.md) | LocalAI Assistant chat modality — adding admin tools to the in-process MCP server, editing skill prompts, keeping REST + MCP + skills in sync |
| [.agents/backend-signing.md](.agents/backend-signing.md) | Backend OCI image signing (keyless cosign + sigstore-go) — producer-side CI setup, consumer-side gallery `verification:` block, strict mode (`LOCALAI_REQUIRE_BACKEND_INTEGRITY`), revocation via `not_before` |
| [.agents/preparing-a-release.md](.agents/preparing-a-release.md) | Cutting a release: PR labels, `RELEASE_NOTES_vX.Y.Z.md`, the blog post under `website/content/blog/`, and the demo clips under `website/static/media/` |
| [.impeccable.md](.impeccable.md) | Design context for UI/UX work — users, brand personality, aesthetic direction, and design principles |
## Quick Reference
@@ -1,12 +1,12 @@
import { test, expect } from './coverage-fixtures.js'
// Alias / Routing template + Manage alias badge regression tests.
// Alias / Routing template + installed model alias badge regression tests.
//
// An alias is a model config with `alias: <target>` that redirects traffic to
// the target model. This covers the two discoverability surfaces:
// - the create-flow template gallery exposes an "Alias / Routing" card that
// seeds a minimal name + alias config
// - the Manage Models tab renders a read-only "alias -> target" badge on
// - the Models Installed view renders a read-only "alias -> target" badge on
// rows that resolve to an alias (looked up via GET /api/aliases, since the
// capabilities row payload doesn't carry the alias field)
@@ -54,7 +54,7 @@ test.describe('Alias template - create flow', () => {
})
})
test.describe('Manage - alias badge', () => {
test.describe('Installed Models - alias badge', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/auth/status', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ authEnabled: false, staticApiKeyRequired: false, providers: [] }) }))
@@ -67,11 +67,9 @@ test.describe('Manage - alias badge', () => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify([{ name: 'gpt-4', target: 'fast-llm' }]) }))
})
test('renders a read-only alias -> target badge on aliased rows', async ({ page }) => {
await page.goto('/app/manage')
// The badge moved off the row and into the pane: it is a fact about the
// model, and the rail line is spent on state.
test('renders a read-only alias target badge on aliased rows', async ({ page }) => {
await page.goto('/app/models?view=installed')
await page.locator('[data-entity="gpt-4"]').click()
await expect(page.getByText('alias -> fast-llm')).toBeVisible({ timeout: 10_000 })
await expect(page.getByText('alias fast-llm')).toBeVisible({ timeout: 10_000 })
})
})
@@ -0,0 +1,295 @@
import { test, expect } from './coverage-fixtures.js'
const catalogBackends = [
{
id: 'llama-cpp',
name: 'llama-cpp',
description: 'GGUF inference',
installed: true,
version: '1.0.0',
isMeta: true,
isAlias: false,
isDevelopment: false,
tags: ['chat'],
},
{
id: 'llama-cpp-cuda12',
name: 'llama-cpp-cuda12',
description: 'CUDA variant',
installed: true,
version: '1.0.0',
isAlias: true,
isDevelopment: false,
tags: ['chat'],
},
{
id: 'llama-cpp-development',
name: 'llama-cpp-development',
description: 'Development build',
installed: true,
version: '1.1.0-dev',
isMeta: true,
isAlias: false,
isDevelopment: true,
tags: ['chat'],
},
]
const installedBackends = [
{
Name: 'llama-cpp',
IsSystem: false,
Version: '1.0.0',
Metadata: { version: '1.0.0', installed_at: '2026-08-15T12:00:00Z' },
},
{
Name: 'llama-cpp-cuda12',
IsSystem: false,
Version: '1.0.0',
Metadata: { version: '1.0.0' },
},
{
Name: 'llama-cpp-development',
IsSystem: false,
Version: '1.1.0-dev',
Metadata: { version: '1.1.0-dev' },
},
]
const upgrades = {
'llama-cpp': {
backend_name: 'llama-cpp',
installed_version: '1.0.0',
available_version: '1.1.0',
},
}
async function mockBackendLifecycle(page) {
await page.route('**/api/backends/upgrades', route => route.fulfill({ json: upgrades }))
await page.route('**/api/backends?*', route => route.fulfill({
json: { backends: catalogBackends },
}))
await page.route('**/backends', route => {
if (new URL(route.request().url()).pathname === '/backends') {
return route.fulfill({ json: installedBackends })
}
return route.continue()
})
await page.route('**/api/nodes', route => route.fulfill({
json: [{ id: 'worker-1', name: 'GPU worker', status: 'healthy', node_type: 'backend' }],
}))
}
const backendRow = (page, name) => page.locator(`[data-entity="${name}"]`)
test.describe('Backends lifecycle page', () => {
test.beforeEach(async ({ page }) => {
await mockBackendLifecycle(page)
})
test('defaults invalid or absent views to Catalog and switches to Installed', async ({ page }) => {
await page.goto('/app/backends')
const catalog = page.getByRole('link', { name: 'Catalog', exact: true })
const installed = page.getByRole('link', { name: 'Installed', exact: true })
await expect(catalog).toHaveAttribute('aria-current', 'page')
await expect(installed).not.toHaveAttribute('aria-current', 'page')
await page.goto('/app/backends?view=unknown')
await expect(catalog).toHaveAttribute('aria-current', 'page')
await expect(installed).not.toHaveAttribute('aria-current', 'page')
await installed.click()
await expect(page).toHaveURL(/[?&]view=installed(?:&|$)/)
await expect(installed).toHaveAttribute('aria-current', 'page')
await expect(backendRow(page, 'llama-cpp')).toBeVisible()
await page.getByRole('textbox', { name: /search installed backends/i }).fill('llama')
await expect(page).toHaveURL(/[?&]q=llama(?:&|$)/)
await page.getByRole('tab', { name: /user/i }).click()
await expect(page).toHaveURL(/[?&]state=user(?:&|$)/)
})
test('restores Installed search, state, selection, and target-node scope from the URL', async ({ page }) => {
await page.goto('/app/backends?view=installed&q=llama&state=upgradable&backend=llama-cpp&target=worker-1')
await expect(page.getByRole('link', { name: 'Installed', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(page.getByRole('textbox', { name: /search installed backends/i })).toHaveValue('llama')
await expect(page.getByRole('tab', { name: /updates/i })).toHaveAttribute('aria-selected', 'true')
await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('llama-cpp')
await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/)
await page.getByRole('link', { name: 'Catalog', exact: true }).click()
await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/)
await expect(page.getByText(/installing only on GPU worker/i)).toBeVisible()
await backendRow(page, 'llama-cpp').click()
await expect(page).toHaveURL(/[?&]backend=llama-cpp(?:&|$)/)
await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/)
})
test('keeps Upgrade and Reinstall on the same upgradable backend', async ({ page }) => {
let reinstallRequests = 0
await page.route('**/api/backends/install/llama-cpp', route => {
reinstallRequests += 1
return route.fulfill({ json: { status: 'ok' } })
})
await page.goto('/app/backends?view=installed&backend=llama-cpp')
await expect(page.getByRole('button', { name: /upgrade to v1\.1\.0/i })).toBeVisible()
await page.getByRole('button', { name: 'Actions for llama-cpp' }).click()
const reinstall = page.getByRole('menuitem', { name: 'Reinstall backend' })
await expect(reinstall).toBeVisible()
await reinstall.click()
await expect.poll(() => reinstallRequests).toBe(1)
})
test('keeps Reinstall beside Upgrade for an installed backend in Catalog', async ({ page }) => {
await page.goto('/app/backends?view=catalog&backend=llama-cpp')
await expect(page.locator('button[title^="Upgrade to"]')).toBeVisible()
await expect(page.locator('button[title="Reinstall"]')).toBeVisible()
})
test('shows a backend action failure inline with the selected backend', async ({ page }) => {
await page.route('**/api/backends/install/llama-cpp', route => route.fulfill({
status: 500,
json: { error: 'registry unavailable' },
}))
await page.goto('/app/backends?view=installed&backend=llama-cpp')
await page.getByRole('button', { name: 'Actions for llama-cpp' }).click()
await page.getByRole('menuitem', { name: 'Reinstall backend' }).click()
const detail = page.locator('[data-testid="backends-installed-pane"]')
await expect(detail.getByRole('alert')).toContainText('registry unavailable')
})
test('shows Upgrade All failures in the rendered global inline error', async ({ page }) => {
await page.route('**/api/backends/upgrade/llama-cpp', route => route.fulfill({
status: 500,
json: { error: 'upgrade registry unavailable' },
}))
await page.goto('/app/backends?view=installed')
await page.getByRole('button', { name: /upgrade all/i }).click()
await expect(page.getByRole('alert')).toContainText('upgrade registry unavailable')
})
test('continues Upgrade All after an earlier backend fails', async ({ page }) => {
let laterUpgradeRequests = 0
await page.route('**/api/backends/upgrades', route => route.fulfill({
json: {
...upgrades,
'llama-cpp-cuda12': {
backend_name: 'llama-cpp-cuda12',
installed_version: '1.0.0',
available_version: '1.1.0',
},
},
}))
await page.route('**/api/backends/upgrade/llama-cpp', route => route.fulfill({
status: 500,
json: { error: 'first registry unavailable' },
}))
await page.route('**/api/backends/upgrade/llama-cpp-cuda12', route => {
laterUpgradeRequests += 1
return route.fulfill({ json: { status: 'ok' } })
})
await page.goto('/app/backends?view=installed')
await page.getByRole('button', { name: /upgrade all/i }).click()
await expect(page.getByRole('alert')).toContainText('first registry unavailable')
await expect.poll(() => laterUpgradeRequests).toBe(1)
})
test('refetches term-sensitive Catalog results after Installed changes and browser history', async ({ page }) => {
const requestedTerms = []
const visionBackend = {
id: 'vision-cpp',
name: 'vision-cpp',
description: 'Vision inference',
installed: false,
isMeta: true,
isAlias: false,
isDevelopment: false,
tags: ['vision'],
}
await page.route('**/api/backends?*', route => {
const term = new URL(route.request().url()).searchParams.get('term') || ''
requestedTerms.push(term)
const backends = term === 'vision'
? [visionBackend]
: term === 'llama'
? [catalogBackends[0]]
: catalogBackends
return route.fulfill({ json: { backends } })
})
await page.goto('/app/backends?view=catalog&q=llama')
await expect.poll(() => requestedTerms.at(-1)).toBe('llama')
await expect(backendRow(page, 'llama-cpp')).toBeVisible()
await page.getByRole('link', { name: 'Installed', exact: true }).click()
await page.getByRole('textbox', { name: /search installed backends/i }).fill('vision')
await page.getByRole('link', { name: 'Catalog', exact: true }).click()
await expect.poll(() => requestedTerms.at(-1)).toBe('vision')
await expect(backendRow(page, 'vision-cpp')).toBeVisible()
await page.goBack()
await expect(page.getByRole('link', { name: 'Installed', exact: true })).toHaveAttribute('aria-current', 'page')
await page.goBack()
await expect(page.getByRole('link', { name: 'Catalog', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(page.getByPlaceholder(/search backends/i)).toHaveValue('llama')
await expect.poll(() => requestedTerms.at(-1)).toBe('llama')
await expect(backendRow(page, 'llama-cpp')).toBeVisible()
await expect(backendRow(page, 'vision-cpp')).toHaveCount(0)
})
test('keeps variants and development builds opt-in', async ({ page }) => {
await page.goto('/app/backends?view=installed')
await expect(backendRow(page, 'llama-cpp')).toBeVisible()
await expect(backendRow(page, 'llama-cpp-cuda12')).toHaveCount(0)
await expect(backendRow(page, 'llama-cpp-development')).toHaveCount(0)
await page.getByText(/variants \(1\)/i).click()
await page.getByText(/development \(1\)/i).click()
await expect(backendRow(page, 'llama-cpp-cuda12')).toBeVisible()
await expect(backendRow(page, 'llama-cpp-development')).toBeVisible()
})
test('preserves confirmation before deleting an installed backend', async ({ page }) => {
let deleteRequests = 0
await page.route('**/api/backends/system/delete/llama-cpp', route => {
deleteRequests += 1
return route.fulfill({ json: { status: 'ok' } })
})
await page.goto('/app/backends?view=installed&backend=llama-cpp')
await page.getByRole('button', { name: 'Actions for llama-cpp' }).click()
await page.getByRole('menuitem', { name: 'Delete backend' }).click()
await expect(page.getByRole('alertdialog')).toContainText('Delete backend llama-cpp?')
expect(deleteRequests).toBe(0)
await page.getByRole('button', { name: 'Delete', exact: true }).click()
await expect.poll(() => deleteRequests).toBe(1)
})
test('narrow detail Back restores focus to the originating backend', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/backends?view=installed')
const backend = backendRow(page, 'llama-cpp')
await backend.click()
await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('llama-cpp')
await expect(backend).not.toBeVisible()
await page.locator('[data-testid="backends-installed-back"]').click()
await expect(backend).toBeVisible()
await expect(backend).toBeFocused()
})
})
@@ -0,0 +1,56 @@
import { test, expect } from './coverage-fixtures.js'
function urlState(page) {
const url = new URL(page.url())
return { path: url.pathname, params: url.searchParams }
}
test.describe('Canonical resource navigation', () => {
test('redirects legacy model management state and replaces browser history', async ({ page }) => {
await page.goto('/app')
await page.goto('/app/manage?tab=models&sel=alpha&mq=alp&mf=running')
await expect.poll(() => urlState(page).path).toBe('/app/models')
const state = urlState(page)
expect(state.params.get('view')).toBe('installed')
expect(state.params.get('model')).toBe('alpha')
expect(state.params.get('q')).toBe('alp')
expect(state.params.get('state')).toBe('running')
await page.goBack()
await expect(page).toHaveURL(/\/app\/?$/)
})
test('redirects legacy backend management state including visibility flags', async ({ page }) => {
await page.goto('/app/manage?tab=backends&sel=llama-cpp&bq=llama&bf=user&bv=1&bd=1')
await expect.poll(() => urlState(page).path).toBe('/app/backends')
const state = urlState(page)
expect(state.params.get('view')).toBe('installed')
expect(state.params.get('backend')).toBe('llama-cpp')
expect(state.params.get('q')).toBe('llama')
expect(state.params.get('state')).toBe('user')
expect(state.params.get('show_all')).toBe('1')
expect(state.params.get('development')).toBe('1')
})
test('defaults legacy management links to Installed Models', async ({ page }) => {
await page.goto('/app/manage')
await expect.poll(() => urlState(page).path).toBe('/app/models')
expect(urlState(page).params.get('view')).toBe('installed')
})
test('names the canonical sidebar destination Models and removes Host from Operate', async ({ page }) => {
await page.goto('/app')
const sidebar = page.locator('.sidebar-nav')
await expect(sidebar.getByRole('link', { name: 'Models', exact: true })).toBeVisible()
await expect(sidebar.getByRole('link', { name: 'Discover', exact: true })).toHaveCount(0)
await sidebar.getByRole('link', { name: 'Operate', exact: true }).click()
const operateRail = page.locator('.console-rail')
await expect(operateRail.getByRole('link', { name: /Backends/ })).toBeVisible()
await expect(operateRail.getByRole('link', { name: /Host/ })).toHaveCount(0)
})
})
+1 -1
View File
@@ -8,7 +8,7 @@ const ROUTES = [
'/app', '/app/chat', '/app/models', '/app/studio', '/app/talk',
'/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs',
'/app/fine-tune', '/app/quantize', '/app/face', '/app/voice',
'/app/manage', '/app/backends', '/app/activity', '/app/operate',
'/app/models?view=installed', '/app/backends', '/app/activity', '/app/operate',
'/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p',
'/app/voice-library', '/app/voice-library/new', '/app/account',
]
+16 -63
View File
@@ -1,26 +1,16 @@
import { test, expect } from './coverage-fixtures.js'
// Small-screen behaviour of the Operate console and the dashboard stat cards.
//
// Both defects here are about a narrow viewport but neither is only a narrow
// viewport problem: the stat cards were being laid out by the wrong rule at
// every width, and the rail's height was never bounded.
test.describe('Operate console on a narrow screen', () => {
test('expanding the rail leaves the page still on screen', async ({ page }) => {
test('expanding the rail leaves the overview on screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/manage')
await page.goto('/app/operate')
const toggle = page.locator('.console-rail-toggle')
await expect(toggle).toBeVisible()
await toggle.click()
await expect(page.locator('.console-rail-groups')).toBeVisible()
// Thirteen destinations in one column is taller than a phone. If opening
// the menu pushes the page's own heading past the fold, the menu has
// replaced the page instead of annotating it.
// Manage titles itself with .view-bar__title rather than .page-title.
const heading = page.locator('.page-title, .view-bar__title').first()
const heading = page.getByRole('heading', { name: 'Overview', exact: true })
const box = await heading.boundingBox()
expect(box).not.toBeNull()
expect(box.y).toBeLessThan(800)
@@ -28,7 +18,7 @@ test.describe('Operate console on a narrow screen', () => {
test('the rail scrolls internally rather than growing without bound', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/manage')
await page.goto('/app/operate')
await page.locator('.console-rail-toggle').click()
const groups = page.locator('.console-rail-groups')
@@ -38,15 +28,13 @@ test.describe('Operate console on a narrow screen', () => {
})
})
test.describe('Headline figures', () => {
// Host used shadowed StatCards; it now shares the Operate overview's hairline
// figure strip, so the guard is that its labels stay legible, not that it
// keeps a card gap.
for (const width of [768, 1024]) {
test(`Host figure labels are not clipped at ${width}px`, async ({ page }) => {
test.describe('Operate headline figures', () => {
for (const width of [390, 768, 1024]) {
test(`labels remain legible at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 })
await page.goto('/app/manage')
const labels = page.locator('.stat-strip__label')
await page.goto('/app/operate')
const labels = page.locator('.operate-headline dt')
await expect(labels.first()).toBeVisible()
const clipped = await labels.evaluateAll(els =>
els.filter(el => el.scrollWidth > el.clientWidth + 1).map(el => el.textContent))
@@ -54,48 +42,13 @@ test.describe('Headline figures', () => {
})
}
test('a Host figure routes into the thing it counts', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 1000 })
await page.goto('/app/manage')
const cell = page.locator('.stat-strip__cell').first()
await expect(cell).toBeVisible()
// A count is worth more when it is also the way to what it counted.
await expect(cell).toHaveJSProperty('tagName', 'BUTTON')
})
test('values remain legible in dark theme', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 950 })
await page.goto('/app/operate')
test('the figure strip keeps its height inside the flex column', async ({ page }) => {
// .page--app is a flex column whose split view takes flex:1, so a child
// with no intrinsic minimum gets shrunk to nothing. This strip did exactly
// that and rendered 2px tall with four invisible cells.
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto('/app/manage')
const strip = page.locator('.manage-summary')
await expect(strip).toBeVisible()
const h = await strip.evaluate(el => el.getBoundingClientRect().height)
expect(h).toBeGreaterThan(40)
})
})
test.describe('Headline figure contrast', () => {
test('every figure is legible against the cell it sits on', async ({ page }) => {
// A <button> does not inherit colour, so a value with no tone rule fell
// back to the UA's `buttontext` — pure black on the dark ground, invisible.
await page.setViewportSize({ width: 1440, height: 950 })
await page.goto('/app/manage')
const bad = await page.locator('.stat-strip__value').evaluateAll(els => els
const invisible = await page.locator('.operate-headline__value').evaluateAll(els => els
.map(el => ({ text: el.textContent, color: getComputedStyle(el).color }))
.filter(v => v.color === 'rgb(0, 0, 0)'))
expect(bad).toEqual([])
})
test('the strip keeps its top margin against the shared shorthand', async ({ page }) => {
// `.stat-strip` declares `margin: 0 0 ...` later in the file, which was
// silently resetting this element's top margin and leaving it flush
// against the resources panel above it.
await page.setViewportSize({ width: 1440, height: 950 })
await page.goto('/app/manage')
const top = await page.locator('.manage-summary')
.evaluate(el => parseFloat(getComputedStyle(el).marginTop))
expect(top).toBeGreaterThan(12)
.filter(value => value.color === 'rgb(0, 0, 0)'))
expect(invisible).toEqual([])
})
})
@@ -19,7 +19,7 @@ const MOCK = {
availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
}
test.describe('Discover - the view scrolls, not the page', () => {
test.describe('Models Explore - the view scrolls, not the page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/models*', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) }))
@@ -12,7 +12,7 @@ const MOCK = {
availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
}
test.describe('Discover - searching keeps the view', () => {
test.describe('Models Explore - searching keeps the view', () => {
test('a refetch keeps the search box, its focus and its value', async ({ page }) => {
let calls = 0
await page.route('**/api/models*', async (route) => {
+1 -1
View File
@@ -69,7 +69,7 @@ test.describe('Home resident models', () => {
await page.goto('/app')
const lanes = page.locator('.lanes--jump .lane')
await expect(lanes).toHaveCount(3)
await expect(lanes.first()).toContainText('Discover')
await expect(lanes.first()).toContainText('Models')
})
test('nothing resident still says so', async ({ page }) => {
@@ -1,69 +0,0 @@
import { test, expect } from './coverage-fixtures.js'
// Host is an inventory, not a catalog, so its split view differs from the two
// galleries in exactly one place: the pane with nothing selected reports what
// is happening rather than offering something to install.
const PANE = '[data-testid="host-pane"]'
const railItems = (page) => page.locator('[data-testid="host-rail-item"]')
const railItem = (page, id) => page.locator(`[data-entity="${id}"]`)
test.describe('Host - split view', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/manage')
await expect(railItems(page).first()).toBeVisible({ timeout: 10_000 })
})
test('the inventory renders no table', async ({ page }) => {
await expect(page.locator('[data-testid="host"]')).toBeVisible()
await expect(page.locator('table thead th')).toHaveCount(0)
})
test('with nothing selected the pane reports the current state', async ({ page }) => {
await expect(page.locator(PANE)).toContainText('Right now')
await expect(page.locator(PANE)).toContainText('Loaded')
await expect(page.locator('[data-testid="host-back"]')).toHaveCount(0)
})
test('choosing a model turns the pane into its detail, and back returns', async ({ page }) => {
const first = railItems(page).first()
const name = await first.getAttribute('data-entity')
await first.click()
await expect(page.locator(PANE)).toContainText(name)
await expect(page.locator(PANE)).toContainText('State')
await expect(page.locator(PANE)).not.toContainText('Right now')
await page.locator('[data-testid="host-back"]').click()
await expect(page.locator(PANE)).toContainText('Right now')
})
test('the selection lives in the URL', async ({ page }) => {
const first = railItems(page).first()
const name = await first.getAttribute('data-entity')
await first.click()
await expect(page).toHaveURL(new RegExp(`[?&]sel=${encodeURIComponent(name)}`))
})
test('the rail buckets by state rather than by capability', async ({ page }) => {
// The opposite of the galleries, and deliberately so: nobody opens Host
// wondering which of their models does vision.
const groups = page.locator('[data-testid^="host-rail-group-"]')
await expect(groups.first()).toBeVisible()
const ids = await groups.evaluateAll(els => els.map(e => e.dataset.testid))
for (const id of ids) {
expect(['host-rail-group-running', 'host-rail-group-idle', 'host-rail-group-disabled']).toContain(id)
}
})
test('switching tabs drops a selection that belonged to the other tab', async ({ page }) => {
await railItems(page).first().click()
await expect(page.locator('[data-testid="host-back"]')).toBeVisible()
// The other tab may legitimately be empty on a fresh host, so the contract
// is that the stale selection is gone, not that a pane appears.
await page.locator('.tab', { hasText: 'Backends' }).click()
await expect(page.locator('[data-testid="host-back"]')).toHaveCount(0)
await expect(page).not.toHaveURL(/[?&]sel=/)
})
})
@@ -0,0 +1,29 @@
import { test, expect } from './coverage-fixtures.js'
// Regression: opening the installed model detail menu must not move the page
// or detach the fixed-position menu from its trigger.
test('the installed model action menu stays beside its trigger', async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 500 })
await page.goto('/app/models?view=installed')
await page.locator('[data-testid="installed-models-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.scrollIntoViewIfNeeded()
const scrollBefore = await page.evaluate(() => window.scrollY)
await trigger.click()
const menu = page.locator('[role="menu"]')
await expect(menu).toBeVisible()
expect(await page.evaluate(() => window.scrollY)).toBe(scrollBefore)
const triggerBox = await trigger.boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).not.toBeNull()
expect(menuBox).not.toBeNull()
const tracksTrigger =
Math.abs(menuBox.y - (triggerBox.y + triggerBox.height)) < 24 ||
Math.abs((menuBox.y + menuBox.height) - triggerBox.y) < 24
expect(tracksTrigger).toBe(true)
await expect(page.locator('body > .popover')).toHaveCount(1)
})
@@ -0,0 +1,24 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Installed model backend logs link', () => {
test('the detail action menu exposes Backend logs with a terminal icon', async ({ page }) => {
await page.goto('/app/models?view=installed')
await page.locator('[data-testid="installed-models-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.click()
const logsItem = page.getByRole('menuitem', { name: 'Backend logs' })
await expect(logsItem).toBeVisible()
await expect(logsItem.locator('i.fa-terminal')).toBeVisible()
})
test('Backend logs navigates to the selected model logs', async ({ page }) => {
await page.goto('/app/models?view=installed')
await page.locator('[data-testid="installed-models-rail-item"]').first().click()
await page.locator('button.action-menu__trigger').first().click()
await page.getByRole('menuitem', { name: 'Backend logs' }).click()
await expect(page).toHaveURL(/\/app\/backend-logs\//)
})
})
@@ -1,50 +0,0 @@
import { test, expect } from './coverage-fixtures.js'
// Regression: opening a row's kebab (ActionMenu) on /app/manage used to snap
// the page scroll to the top and render the menu detached from its trigger,
// making it impossible to operate. Two causes: the menu auto-focus scrolled
// the page (no preventScroll), and the position:fixed popover was rendered
// inside a row whose hover `transform` re-anchored it. Fix portals the popover
// to document.body, positions it before paint, and focuses without scrolling.
test.describe('Manage Page - Action menu positioning', () => {
test('opening the pane menu keeps scroll stable and places it by its trigger', async ({ page }) => {
// Small viewport so the page is scrollable and a scroll jump is observable.
await page.setViewportSize({ width: 1024, height: 500 })
await page.goto('/app/manage')
await page.locator('[data-testid="host-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
// Bring the trigger into view ourselves first, so the only scroll we then
// measure is the one the menu would (wrongly) cause - not Playwright's own
// scroll-into-view before the click.
await trigger.scrollIntoViewIfNeeded()
const scrollBefore = await page.evaluate(() => window.scrollY)
await trigger.click()
const menu = page.locator('[role="menu"]')
await expect(menu).toBeVisible()
// Behavioural symptom 1: focusing the menu must not yank the page scroll.
const scrollAfter = await page.evaluate(() => window.scrollY)
expect(scrollAfter).toBe(scrollBefore)
// Behavioural symptom 2: the menu must sit next to its trigger, not float
// at the top of the window where it can't be operated.
const triggerBox = await trigger.boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).not.toBeNull()
expect(menuBox).not.toBeNull()
// Menu top is within ~24px of the trigger's bottom (below) or above it
// (flipped) — in all cases it tracks the trigger, never floating at y≈0.
const tracksTrigger =
Math.abs(menuBox.y - (triggerBox.y + triggerBox.height)) < 24 ||
Math.abs((menuBox.y + menuBox.height) - triggerBox.y) < 24
expect(tracksTrigger).toBe(true)
// Mechanism: the popover must be portaled to document.body so position:fixed
// resolves against the viewport, not a transformed ancestor row.
await expect(page.locator('body > .popover')).toHaveCount(1)
})
})
@@ -1,31 +0,0 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Manage Page - Backend Logs Link', () => {
test('the pane action menu exposes Backend logs with a terminal icon', async ({ page }) => {
await page.goto('/app/manage')
// Actions moved out of the row and into the pane, so reaching them is now a
// selection followed by the pane's kebab.
await page.locator('[data-testid="host-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.click()
const logsItem = page.getByRole('menuitem', { name: 'Backend logs' })
await expect(logsItem).toBeVisible()
await expect(logsItem.locator('i.fa-terminal')).toBeVisible()
})
test('Backend logs menu item navigates to backend-logs page', async ({ page }) => {
await page.goto('/app/manage')
await page.locator('[data-testid="host-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.click()
const logsItem = page.getByRole('menuitem', { name: 'Backend logs' })
await expect(logsItem).toBeVisible()
await logsItem.click()
await expect(page).toHaveURL(/\/app\/backend-logs\//)
})
})
@@ -44,21 +44,20 @@ test.describe('Model Editor — Back navigation', () => {
await mockEditorEndpoints(page)
})
test('Back returns to Manage with a "Back to System" caption', async ({ page }) => {
await page.goto('/app/manage')
// Actions live in the pane now, so select something first.
await page.locator('[data-testid="host-rail-item"]').first().click()
test('Back returns to Installed Models with a "Back to Models" caption', async ({ page }) => {
await page.goto('/app/models?view=installed')
await page.locator('[data-testid="installed-models-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.click()
await page.getByRole('menuitem', { name: 'Edit configuration' }).click()
await expect(page).toHaveURL(/\/app\/model-editor\//)
const back = page.getByRole('button', { name: /Back to System/ })
const back = page.getByRole('button', { name: /Back to Models/ })
await expect(back).toBeVisible({ timeout: 10_000 })
await back.click()
await expect(page).toHaveURL(/\/app\/manage/)
await expect(page).toHaveURL(/\/app\/models\?view=installed/)
})
test('returns to the originating Middleware tab (?tab=routing) it was opened from', async ({ page }) => {
@@ -86,8 +85,8 @@ test.describe('Model Editor — Back navigation', () => {
await expect(page.getByText('smart-router').first()).toBeVisible()
})
test('falls back to "Back to Manage" on a direct visit with no origin state', async ({ page }) => {
test('falls back to Installed Models on a direct visit with no origin state', async ({ page }) => {
await page.goto('/app/model-editor/mock-model')
await expect(page.getByRole('button', { name: /Back to System/ })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /Back to Models/ })).toBeVisible({ timeout: 10_000 })
})
})
@@ -1677,7 +1677,7 @@ const MOCK_MULTI_CONTEXT_ESTIMATES = {
},
};
test.describe("Models Gallery - Discover split view", () => {
test.describe("Models Gallery - Explore split view", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/models*", (route) => {
route.fulfill({
@@ -0,0 +1,274 @@
import { test, expect } from './coverage-fixtures.js'
const installedModels = [
{
id: 'alpha',
backend: 'llama-cpp',
capabilities: ['FLAG_CHAT'],
pinned: true,
},
{
id: 'beta',
backend: 'llama-cpp',
capabilities: ['embeddings'],
},
{
id: 'all',
backend: 'llama-cpp',
capabilities: ['chat'],
},
{
id: 'remote-model',
backend: 'llama-cpp',
capabilities: ['chat'],
loaded_on: [{
node_id: 'worker-1',
node_name: 'Worker one',
node_status: 'healthy',
state: 'loaded',
}],
},
{
id: 'disabled-model',
backend: 'llama-cpp',
capabilities: ['chat'],
disabled: true,
},
]
const galleryModels = installedModels.map(model => ({
name: model.id,
backend: model.backend,
installed: true,
description: `Gallery details for ${model.id}`,
tags: model.capabilities,
}))
async function mockModelLifecycle(page) {
let loadedModels = ['alpha']
await page.route('**/api/models/capabilities', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ data: installedModels }),
}))
await page.route('**/api/models?*', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
models: galleryModels,
allBackends: ['llama-cpp'],
availableModels: galleryModels.length,
installedModels: galleryModels.length,
totalPages: 1,
}),
}))
await page.route('**/api/models', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
models: galleryModels,
allBackends: ['llama-cpp'],
availableModels: galleryModels.length,
installedModels: galleryModels.length,
totalPages: 1,
}),
}))
await page.route('**/api/models/estimate/*', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({}),
}))
await page.route('**/api/backends/usecases', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ 'llama-cpp': ['chat', 'embeddings'] }),
}))
await page.route('**/api/aliases', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify([]),
}))
await page.route('**/api/nodes', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify([{ id: 'worker-1', name: 'Worker one' }]),
}))
await page.route('**/system', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ loaded_models: loadedModels.map(id => ({ id })) }),
}))
await page.route('**/backend/shutdown', async route => {
const body = route.request().postDataJSON()
loadedModels = loadedModels.filter(id => id !== body.model)
await route.fulfill({ contentType: 'application/json', body: '{}' })
})
}
const installedRail = page => page.locator('[data-testid="installed-models"]')
const installedPane = page => page.locator('[data-testid="installed-models-pane"]')
test.describe('Models lifecycle', () => {
test.beforeEach(async ({ page }) => {
await mockModelLifecycle(page)
})
test('Explore is the default for absent and invalid views', async ({ page }) => {
await page.goto('/app/models')
const explore = page.getByRole('link', { name: 'Explore', exact: true })
const installed = page.getByRole('link', { name: 'Installed', exact: true })
await expect(explore).toHaveAttribute('aria-current', 'page')
await expect(installed).not.toHaveAttribute('aria-current', 'page')
await expect(page.locator('[data-testid="discover"]')).toBeVisible()
await page.goto('/app/models?view=not-a-view')
await expect(explore).toHaveAttribute('aria-current', 'page')
await expect(page.locator('[data-testid="discover"]')).toBeVisible()
})
test('an installed Explore model opens or moves to management without destructive actions', async ({ page }) => {
await page.goto('/app/models?model=alpha')
await expect(page.getByRole('button', { name: 'Open Chat' })).toBeVisible()
const manage = page.getByRole('button', { name: 'Manage installation' })
await expect(manage).toBeVisible()
await expect(page.getByRole('button', { name: 'Delete', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Reinstall', exact: true })).toHaveCount(0)
await manage.click()
await expect(page).toHaveURL(/[?&]view=installed/)
await expect(page).toHaveURL(/[?&]model=alpha/)
})
test('switches to Installed and restores URL state through history', async ({ page }) => {
await page.goto('/app/models')
await page.getByRole('link', { name: 'Installed', exact: true }).click()
await expect(page).toHaveURL(/[?&]view=installed/)
await expect(page.getByRole('link', { name: 'Installed', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(installedRail(page)).toBeVisible()
const search = page.getByRole('textbox', { name: 'Search installed models' })
await search.fill('beta')
await page.getByRole('tab', { name: /Idle$/ }).click()
await page.locator('[data-entity="beta"]').click()
await expect(page).toHaveURL(/[?&]q=beta/)
await expect(page).toHaveURL(/[?&]state=idle/)
await expect(page).toHaveURL(/[?&]model=beta/)
await expect(installedPane(page)).toContainText('beta')
await page.goBack()
await expect(page).not.toHaveURL(/[?&]model=beta/)
await expect(search).toHaveValue('beta')
await expect(page.getByRole('tab', { name: /Idle$/ })).toHaveAttribute('aria-selected', 'true')
await page.goForward()
await expect(page).toHaveURL(/[?&]model=beta/)
await expect(installedPane(page)).toContainText('beta')
})
test('restores a selected installed model and runtime state from the URL', async ({ page }) => {
await page.goto('/app/models?view=installed&q=remote&state=distributed&model=remote-model')
await expect(page.getByRole('textbox', { name: 'Search installed models' })).toHaveValue('remote')
await expect(page.getByRole('tab', { name: /Distributed$/ })).toHaveAttribute('aria-selected', 'true')
await expect(installedPane(page)).toContainText('remote-model')
await expect(installedPane(page)).toContainText('Worker one')
})
test('stops a running model with confirmation', async ({ page }) => {
await page.goto('/app/models?view=installed&model=alpha')
const stop = page.getByRole('button', { name: 'Stop', exact: true })
await expect(stop).toBeVisible()
await stop.click()
await expect(page.getByRole('alertdialog')).toContainText('Stop model alpha?')
const requestPromise = page.waitForRequest(request => request.url().endsWith('/backend/shutdown'))
await page.getByRole('alertdialog').getByRole('button', { name: 'Stop', exact: true }).click()
const request = await requestPromise
expect(request.postDataJSON()).toEqual({ model: 'alpha' })
await expect(page.getByRole('button', { name: 'Load', exact: true })).toBeVisible({ timeout: 3_000 })
})
test('keeps a runtime failure inline with its model', async ({ page }) => {
await page.route('**/backend/load', route => route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'engine unavailable' } }),
}))
await page.goto('/app/models?view=installed&model=beta')
await page.getByRole('button', { name: 'Load', exact: true }).click()
const alert = installedPane(page).getByRole('alert')
await expect(alert).toContainText('Could not load beta: engine unavailable')
})
test('keeps a failed delete selected so its error remains inline', async ({ page }) => {
await page.route('**/models/delete/beta', route => route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'model is busy' } }),
}))
await page.goto('/app/models?view=installed&model=beta')
await page.getByRole('button', { name: 'Actions for beta' }).click()
await page.getByRole('menuitem', { name: 'Delete model' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete model' }).click()
await expect(installedPane(page).getByRole('heading', { name: 'beta', exact: true })).toBeVisible()
await expect(installedPane(page).getByRole('alert')).toContainText('Could not delete beta: model is busy')
})
test('clears selection after deleting an installed model', async ({ page }) => {
await page.route('**/models/delete/beta', route => route.fulfill({
contentType: 'application/json',
body: '{}',
}))
await page.goto('/app/models?view=installed&model=beta')
await page.getByRole('button', { name: 'Actions for beta' }).click()
await page.getByRole('menuitem', { name: 'Delete model' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete model' }).click()
await expect(page).not.toHaveURL(/[?&]model=beta(?:&|$)/)
await expect(installedPane(page).getByRole('heading', { name: 'beta', exact: true })).toHaveCount(0)
})
test('refreshes distributed runtime state every ten seconds', async ({ page }) => {
let runtimeRequests = 0
await page.route('**/system', route => {
runtimeRequests += 1
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ loaded_models: [{ id: 'alpha' }] }),
})
})
await page.goto('/app/models?view=installed')
await expect.poll(() => runtimeRequests).toBeGreaterThan(0)
const initialRequests = runtimeRequests
await expect.poll(() => runtimeRequests, { timeout: 12_000 }).toBeGreaterThan(initialRequests)
})
test('preserves literal all values for search and model selection', async ({ page }) => {
await page.goto('/app/models?view=installed&q=all&model=all')
await expect(page.getByRole('textbox', { name: 'Search installed models' })).toHaveValue('all')
await expect(installedPane(page).getByRole('heading', { name: 'all', exact: true })).toBeVisible()
await expect(page).toHaveURL(/[?&]q=all(?:&|$)/)
await expect(page).toHaveURL(/[?&]model=all(?:&|$)/)
})
test('narrow detail Back restores focus to the originating model', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/models?view=installed')
const model = page.locator('[data-entity="beta"]')
await model.click()
await expect(page.locator('[data-testid="installed-models-pane"]')).toContainText('beta')
await expect(model).not.toBeVisible()
await page.locator('[data-testid="installed-models-back"]').click()
await expect(model).toBeVisible()
await expect(model).toBeFocused()
})
})
+4 -7
View File
@@ -12,15 +12,12 @@ test.describe('Navigation', () => {
await expect(page.locator('.home-page')).toBeVisible()
})
test('top menu exposes Home and Discover', async ({ page }) => {
test('top menu exposes Home and Models', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.sidebar-nav a.nav-item[href="/app"]')).toBeVisible()
const discover = page.locator('.sidebar-nav a.nav-item[href="/app/models"]')
await expect(discover).toBeVisible()
// The label is asserted, not just the destination: a bare "Models" would
// name the same thing as the installed-models view under Host, which is
// the collision the rename exists to remove.
await expect(discover.locator('.nav-label')).toHaveText('Discover')
const models = page.locator('.sidebar-nav a.nav-item[href="/app/models"]')
await expect(models).toBeVisible()
await expect(models.locator('.nav-label')).toHaveText('Models')
})
test('Create stays an inline tier with Chat, Studio and Talk', async ({ page }) => {
@@ -169,4 +169,47 @@ test.describe('Operate overview headline', () => {
await expect(runtime).toContainText('backends')
await expect(runtime).toContainText('running')
})
test('shows host capacity from the shared Operate summary', async ({ page }) => {
await page.route('**/api/resources', route => route.fulfill({
json: {
type: 'gpu',
gpus: [{ name: 'NVIDIA L40S', vendor: 'NVIDIA', usage_percent: 42, used_vram: 10_000, total_vram: 24_000 }],
aggregate: { gpu_count: 1 },
storage_size: 12_000,
},
}))
await mockQuiet(page)
await page.goto('/app/operate')
const capacity = page.locator('[data-testid="operate-capacity"]')
await expect(capacity).toBeVisible()
await expect(capacity).toContainText('Host capacity')
await expect(capacity).toContainText('NVIDIA L40S')
await expect(capacity).toContainText('42%')
})
test('states when host capacity is unavailable', async ({ page }) => {
await page.route('**/api/resources', route => route.fulfill({
status: 503,
json: { error: 'resource monitor disabled' },
}))
await mockQuiet(page)
await page.goto('/app/operate')
const capacity = page.locator('[data-testid="operate-capacity"]')
await expect(capacity).toContainText('Host capacity unavailable')
})
test('states when the host reports no capacity fields', async ({ page }) => {
await page.route('**/api/resources', route => route.fulfill({ json: {} }))
await mockQuiet(page)
await page.goto('/app/operate')
const capacity = page.locator('[data-testid="operate-capacity"]')
await expect(capacity).toContainText('No capacity data reported')
})
})
@@ -18,7 +18,7 @@ const PAGES = [
['/app/usage', 'Usage'],
['/app/account', 'Account'],
['/app/studio', 'Studio'],
['/app/manage', 'Manage'],
['/app/models', 'Models'],
['/app/operate', 'Operate overview'],
['/app/backends', 'Backends'],
['/app/activity', 'Activity'],
+1 -1
View File
@@ -1 +1 @@
535
520
@@ -110,6 +110,70 @@
}
},
"backends": {
"lifecycle": {
"navigation": "Backend lifecycle",
"catalog": "Catalog",
"installed": "Installed",
"installedAria": "Installed backends",
"searchPlaceholder": "Search installed backends by name or alias...",
"filterAll": "All",
"filterUser": "User",
"filterSystem": "System",
"filterUpdates": "Updates",
"filterOffline": "Offline nodes",
"variants": "Variants",
"variantsCount": "Variants ({{count}})",
"development": "Development",
"developmentCount": "Development ({{count}})",
"countLabel": "{{visible}} of {{total}}",
"emptyTitle": "No backends installed yet",
"emptyBody": "Install a backend from the Catalog to give this host a runtime for models.",
"noMatches": "No installed backends match the current search and filters.",
"clearFilters": "Clear filters",
"updatesAvailable_one": "{{count}} backend has an update available",
"updatesAvailable_other": "{{count}} backends have updates available",
"upgradeAll": "Upgrade all",
"upgrading": "Upgrading...",
"upgradeAllFailed": "Upgrade failed for {{name}}: {{message}}",
"upgradeAllStarted_one": "Upgrade started for {{count}} backend",
"upgradeAllStarted_other": "Upgrade started for {{count}} backends",
"loadFailed": "Failed to load installed backends: {{message}}",
"reinstall": "Reinstall backend",
"reinstallStarted": "Reinstalling {{name}}...",
"reinstallFailed": "Failed to reinstall: {{message}}",
"upgrade": "Upgrade",
"upgradeTo": "Upgrade to v{{version}}",
"upgradeStarted": "Upgrading {{name}}...",
"upgradeFailed": "Failed to upgrade: {{message}}",
"delete": "Delete",
"deleteBackend": "Delete backend",
"deleteTitle": "Delete Backend",
"deleteMessage": "Delete backend {{name}}?",
"deleteSucceeded": "Deleted backend {{name}}",
"deleteFailed": "Failed to delete backend: {{message}}",
"actionsFor": "Actions for {{name}}",
"protected": "Protected",
"protectedTitle": "System backends are managed outside the gallery",
"allBackends": "All backends",
"version": "Version",
"available": "Available",
"managed": "Managed",
"system": "System",
"gallery": "Gallery",
"installedOn": "Installed on",
"description": "Description",
"source": "Source",
"digest": "Digest",
"installedAt": "Installed",
"working": "Working...",
"updateAvailable": "Update available",
"updateGroup": "Update available",
"installedGroup": "Installed",
"inventoryEyebrow": "Installed runtimes",
"inventoryTitle_one": "{{count}} backend is installed.",
"inventoryTitle_other": "{{count}} backends are installed.",
"inventoryBody": "Select a backend to inspect its source, version, placement, and lifecycle actions."
},
"title": "Backend-Verwaltung",
"subtitle": "Entdecken und installieren Sie KI-Backends für Ihre Modelle"
},
@@ -155,6 +219,20 @@
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"capacity": {
"title": "Host-Kapazität",
"loading": "Host-Kapazität wird geladen...",
"unavailable": "Host-Kapazität nicht verfügbar",
"empty": "Keine Kapazitätsdaten gemeldet",
"gpus": "{{count}} GPUs",
"reclaimer": "Reclaimer active",
"used": "Used",
"total": "Total",
"systemRam": "System RAM",
"memory": "Memory",
"totalVram": "Total VRAM",
"storage": "Models storage"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
@@ -94,8 +94,8 @@
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"models": "Models",
"modelsSummary": "Explore, install, and manage models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
@@ -1,5 +1,59 @@
{
"title": "Entdecken",
"lifecycle": {
"title": "Models",
"navLabel": "Model lifecycle",
"views": { "explore": "Explore", "installed": "Installed" },
"filters": { "all": "All", "running": "Running", "idle": "Idle", "disabled": "Disabled", "pinned": "Pinned", "distributed": "Distributed" },
"installed": {
"searchPlaceholder": "Search installed models",
"count": "{{shown}} of {{total}}",
"backToAll": "All installed models",
"eyebrow": "Installed on this host",
"summary": "{{count}} models ready to serve.",
"summaryHint": "Select a model to inspect its runtime state and controls."
},
"states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" },
"actions": {
"load": "Load", "loading": "Loading…", "stop": "Stop", "update": "Update", "updating": "Updating…",
"enable": "Enable model", "disable": "Disable model", "pin": "Pin (prevent idle unload)",
"unpin": "Unpin (allow idle unload)", "edit": "Edit configuration", "logs": "Backend logs",
"delete": "Delete model", "forModel": "Actions for {{model}}",
"open": "Open {{useCase}}", "manageInstallation": "Manage installation"
},
"actionNames": { "load": "load", "stop": "stop", "enable": "enable", "disable": "disable", "pin": "pin", "unpin": "unpin", "delete": "delete", "update": "update" },
"toasts": {
"loaded": "Loaded {{model}}", "stopped": "Stopped {{model}}", "enabled": "Enabled {{model}}",
"disabled": "Disabled {{model}}", "pinned": "Pinned {{model}}", "unpinned": "Unpinned {{model}}",
"deleted": "Deleted {{model}}", "updated": "Models updated"
},
"confirm": {
"stopTitle": "Stop Model", "stopMessage": "Stop model {{model}}?",
"deleteTitle": "Delete Model", "deleteMessage": "Delete model {{model}}? This cannot be undone."
},
"errors": {
"action": "Could not {{action}} {{model}}: {{message}}",
"loadList": "Could not load installed models: {{message}}"
},
"detail": {
"title": "Details", "state": "State", "backend": "Backend", "auto": "Auto", "pinned": "Pinned", "yes": "yes",
"description": "Description", "noDescription": "No gallery description is available for this model.",
"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}}"
},
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
"explore": "Explore models", "import": "Import model", "noMatches": "No installed models match these filters.",
"clear": "Clear filters"
}
},
"title": "Models",
"subtitle": "Durchsuchen und installieren Sie KI-Modelle aus der Galerie",
"recommended": {
"title": "Empfohlen für Ihre Hardware",
@@ -30,7 +30,7 @@
},
"items": {
"home": "Start",
"discover": "Entdecken",
"models": "Models",
"chat": "Chat",
"studio": "Studio",
"talk": "Sprechen",
@@ -40,7 +40,6 @@
"voices": "Stimmen",
"jobs": "Aufgaben",
"operate": "Verwaltung",
"host": "Host",
"audioTransform": "Audio transformieren",
"faceRecognition": "Gesichtserkennung",
"voiceRecognition": "Spracherkennung",
@@ -110,6 +110,70 @@
}
},
"backends": {
"lifecycle": {
"navigation": "Backend lifecycle",
"catalog": "Catalog",
"installed": "Installed",
"installedAria": "Installed backends",
"searchPlaceholder": "Search installed backends by name or alias...",
"filterAll": "All",
"filterUser": "User",
"filterSystem": "System",
"filterUpdates": "Updates",
"filterOffline": "Offline nodes",
"variants": "Variants",
"variantsCount": "Variants ({{count}})",
"development": "Development",
"developmentCount": "Development ({{count}})",
"countLabel": "{{visible}} of {{total}}",
"emptyTitle": "No backends installed yet",
"emptyBody": "Install a backend from the Catalog to give this host a runtime for models.",
"noMatches": "No installed backends match the current search and filters.",
"clearFilters": "Clear filters",
"updatesAvailable_one": "{{count}} backend has an update available",
"updatesAvailable_other": "{{count}} backends have updates available",
"upgradeAll": "Upgrade all",
"upgrading": "Upgrading...",
"upgradeAllFailed": "Upgrade failed for {{name}}: {{message}}",
"upgradeAllStarted_one": "Upgrade started for {{count}} backend",
"upgradeAllStarted_other": "Upgrade started for {{count}} backends",
"loadFailed": "Failed to load installed backends: {{message}}",
"reinstall": "Reinstall backend",
"reinstallStarted": "Reinstalling {{name}}...",
"reinstallFailed": "Failed to reinstall: {{message}}",
"upgrade": "Upgrade",
"upgradeTo": "Upgrade to v{{version}}",
"upgradeStarted": "Upgrading {{name}}...",
"upgradeFailed": "Failed to upgrade: {{message}}",
"delete": "Delete",
"deleteBackend": "Delete backend",
"deleteTitle": "Delete Backend",
"deleteMessage": "Delete backend {{name}}?",
"deleteSucceeded": "Deleted backend {{name}}",
"deleteFailed": "Failed to delete backend: {{message}}",
"actionsFor": "Actions for {{name}}",
"protected": "Protected",
"protectedTitle": "System backends are managed outside the gallery",
"allBackends": "All backends",
"version": "Version",
"available": "Available",
"managed": "Managed",
"system": "System",
"gallery": "Gallery",
"installedOn": "Installed on",
"description": "Description",
"source": "Source",
"digest": "Digest",
"installedAt": "Installed",
"working": "Working...",
"updateAvailable": "Update available",
"updateGroup": "Update available",
"installedGroup": "Installed",
"inventoryEyebrow": "Installed runtimes",
"inventoryTitle_one": "{{count}} backend is installed.",
"inventoryTitle_other": "{{count}} backends are installed.",
"inventoryBody": "Select a backend to inspect its source, version, placement, and lifecycle actions."
},
"title": "Backend Management",
"subtitle": "Discover and install AI backends to power your models"
},
@@ -178,6 +242,20 @@
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"capacity": {
"title": "Host capacity",
"loading": "Loading host capacity...",
"unavailable": "Host capacity unavailable",
"empty": "No capacity data reported",
"gpus": "{{count}} GPUs",
"reclaimer": "Reclaimer active",
"used": "Used",
"total": "Total",
"systemRam": "System RAM",
"memory": "Memory",
"totalVram": "Total VRAM",
"storage": "Models storage"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
@@ -109,8 +109,8 @@
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"models": "Models",
"modelsSummary": "Explore, install, and manage models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
@@ -9,7 +9,7 @@
},
"actions": {
"backTo": "Back to {{page}}",
"system": "System",
"models": "Models",
"templates": "Templates",
"createModel": "Create Model",
"saveChanges": "Save Changes",
@@ -1,5 +1,59 @@
{
"title": "Discover",
"lifecycle": {
"title": "Models",
"navLabel": "Model lifecycle",
"views": { "explore": "Explore", "installed": "Installed" },
"filters": { "all": "All", "running": "Running", "idle": "Idle", "disabled": "Disabled", "pinned": "Pinned", "distributed": "Distributed" },
"installed": {
"searchPlaceholder": "Search installed models",
"count": "{{shown}} of {{total}}",
"backToAll": "All installed models",
"eyebrow": "Installed on this host",
"summary": "{{count}} models ready to serve.",
"summaryHint": "Select a model to inspect its runtime state and controls."
},
"states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" },
"actions": {
"load": "Load", "loading": "Loading…", "stop": "Stop", "update": "Update", "updating": "Updating…",
"enable": "Enable model", "disable": "Disable model", "pin": "Pin (prevent idle unload)",
"unpin": "Unpin (allow idle unload)", "edit": "Edit configuration", "logs": "Backend logs",
"delete": "Delete model", "forModel": "Actions for {{model}}",
"open": "Open {{useCase}}", "manageInstallation": "Manage installation"
},
"actionNames": { "load": "load", "stop": "stop", "enable": "enable", "disable": "disable", "pin": "pin", "unpin": "unpin", "delete": "delete", "update": "update" },
"toasts": {
"loaded": "Loaded {{model}}", "stopped": "Stopped {{model}}", "enabled": "Enabled {{model}}",
"disabled": "Disabled {{model}}", "pinned": "Pinned {{model}}", "unpinned": "Unpinned {{model}}",
"deleted": "Deleted {{model}}", "updated": "Models updated"
},
"confirm": {
"stopTitle": "Stop Model", "stopMessage": "Stop model {{model}}?",
"deleteTitle": "Delete Model", "deleteMessage": "Delete model {{model}}? This cannot be undone."
},
"errors": {
"action": "Could not {{action}} {{model}}: {{message}}",
"loadList": "Could not load installed models: {{message}}"
},
"detail": {
"title": "Details", "state": "State", "backend": "Backend", "auto": "Auto", "pinned": "Pinned", "yes": "yes",
"description": "Description", "noDescription": "No gallery description is available for this model.",
"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}}"
},
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
"explore": "Explore models", "import": "Import model", "noMatches": "No installed models match these filters.",
"clear": "Clear filters"
}
},
"title": "Models",
"subtitle": "Browse and install AI models from the gallery",
"models": "Models",
"recommended": {
@@ -30,7 +30,7 @@
},
"items": {
"home": "Home",
"discover": "Discover",
"models": "Models",
"chat": "Chat",
"studio": "Studio",
"talk": "Talk",
@@ -40,7 +40,6 @@
"voices": "Voices",
"jobs": "Jobs",
"operate": "Admin",
"host": "Host",
"audioTransform": "Audio Transform",
"faceRecognition": "Face Recognition",
"voiceRecognition": "Voice Recognition",
@@ -110,6 +110,70 @@
}
},
"backends": {
"lifecycle": {
"navigation": "Backend lifecycle",
"catalog": "Catalog",
"installed": "Installed",
"installedAria": "Installed backends",
"searchPlaceholder": "Search installed backends by name or alias...",
"filterAll": "All",
"filterUser": "User",
"filterSystem": "System",
"filterUpdates": "Updates",
"filterOffline": "Offline nodes",
"variants": "Variants",
"variantsCount": "Variants ({{count}})",
"development": "Development",
"developmentCount": "Development ({{count}})",
"countLabel": "{{visible}} of {{total}}",
"emptyTitle": "No backends installed yet",
"emptyBody": "Install a backend from the Catalog to give this host a runtime for models.",
"noMatches": "No installed backends match the current search and filters.",
"clearFilters": "Clear filters",
"updatesAvailable_one": "{{count}} backend has an update available",
"updatesAvailable_other": "{{count}} backends have updates available",
"upgradeAll": "Upgrade all",
"upgrading": "Upgrading...",
"upgradeAllFailed": "Upgrade failed for {{name}}: {{message}}",
"upgradeAllStarted_one": "Upgrade started for {{count}} backend",
"upgradeAllStarted_other": "Upgrade started for {{count}} backends",
"loadFailed": "Failed to load installed backends: {{message}}",
"reinstall": "Reinstall backend",
"reinstallStarted": "Reinstalling {{name}}...",
"reinstallFailed": "Failed to reinstall: {{message}}",
"upgrade": "Upgrade",
"upgradeTo": "Upgrade to v{{version}}",
"upgradeStarted": "Upgrading {{name}}...",
"upgradeFailed": "Failed to upgrade: {{message}}",
"delete": "Delete",
"deleteBackend": "Delete backend",
"deleteTitle": "Delete Backend",
"deleteMessage": "Delete backend {{name}}?",
"deleteSucceeded": "Deleted backend {{name}}",
"deleteFailed": "Failed to delete backend: {{message}}",
"actionsFor": "Actions for {{name}}",
"protected": "Protected",
"protectedTitle": "System backends are managed outside the gallery",
"allBackends": "All backends",
"version": "Version",
"available": "Available",
"managed": "Managed",
"system": "System",
"gallery": "Gallery",
"installedOn": "Installed on",
"description": "Description",
"source": "Source",
"digest": "Digest",
"installedAt": "Installed",
"working": "Working...",
"updateAvailable": "Update available",
"updateGroup": "Update available",
"installedGroup": "Installed",
"inventoryEyebrow": "Installed runtimes",
"inventoryTitle_one": "{{count}} backend is installed.",
"inventoryTitle_other": "{{count}} backends are installed.",
"inventoryBody": "Select a backend to inspect its source, version, placement, and lifecycle actions."
},
"title": "Administración de backends",
"subtitle": "Descubre e instala backends de IA para tus modelos"
},
@@ -155,6 +219,20 @@
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"capacity": {
"title": "Capacidad del host",
"loading": "Cargando la capacidad del host...",
"unavailable": "Capacidad del host no disponible",
"empty": "No se han notificado datos de capacidad",
"gpus": "{{count}} GPUs",
"reclaimer": "Reclaimer active",
"used": "Used",
"total": "Total",
"systemRam": "System RAM",
"memory": "Memory",
"totalVram": "Total VRAM",
"storage": "Models storage"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
@@ -94,8 +94,8 @@
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"models": "Models",
"modelsSummary": "Explore, install, and manage models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
@@ -1,5 +1,59 @@
{
"title": "Descubrir",
"lifecycle": {
"title": "Models",
"navLabel": "Model lifecycle",
"views": { "explore": "Explore", "installed": "Installed" },
"filters": { "all": "All", "running": "Running", "idle": "Idle", "disabled": "Disabled", "pinned": "Pinned", "distributed": "Distributed" },
"installed": {
"searchPlaceholder": "Search installed models",
"count": "{{shown}} of {{total}}",
"backToAll": "All installed models",
"eyebrow": "Installed on this host",
"summary": "{{count}} models ready to serve.",
"summaryHint": "Select a model to inspect its runtime state and controls."
},
"states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" },
"actions": {
"load": "Load", "loading": "Loading…", "stop": "Stop", "update": "Update", "updating": "Updating…",
"enable": "Enable model", "disable": "Disable model", "pin": "Pin (prevent idle unload)",
"unpin": "Unpin (allow idle unload)", "edit": "Edit configuration", "logs": "Backend logs",
"delete": "Delete model", "forModel": "Actions for {{model}}",
"open": "Open {{useCase}}", "manageInstallation": "Manage installation"
},
"actionNames": { "load": "load", "stop": "stop", "enable": "enable", "disable": "disable", "pin": "pin", "unpin": "unpin", "delete": "delete", "update": "update" },
"toasts": {
"loaded": "Loaded {{model}}", "stopped": "Stopped {{model}}", "enabled": "Enabled {{model}}",
"disabled": "Disabled {{model}}", "pinned": "Pinned {{model}}", "unpinned": "Unpinned {{model}}",
"deleted": "Deleted {{model}}", "updated": "Models updated"
},
"confirm": {
"stopTitle": "Stop Model", "stopMessage": "Stop model {{model}}?",
"deleteTitle": "Delete Model", "deleteMessage": "Delete model {{model}}? This cannot be undone."
},
"errors": {
"action": "Could not {{action}} {{model}}: {{message}}",
"loadList": "Could not load installed models: {{message}}"
},
"detail": {
"title": "Details", "state": "State", "backend": "Backend", "auto": "Auto", "pinned": "Pinned", "yes": "yes",
"description": "Description", "noDescription": "No gallery description is available for this model.",
"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}}"
},
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
"explore": "Explore models", "import": "Import model", "noMatches": "No installed models match these filters.",
"clear": "Clear filters"
}
},
"title": "Models",
"subtitle": "Explora e instala modelos de IA desde la galería",
"recommended": {
"title": "Recomendado para tu hardware",
@@ -30,7 +30,7 @@
},
"items": {
"home": "Inicio",
"discover": "Descubrir",
"models": "Models",
"chat": "Chat",
"studio": "Studio",
"talk": "Hablar",
@@ -40,7 +40,6 @@
"voices": "Voces",
"jobs": "Trabajos",
"operate": "Administración",
"host": "Host",
"audioTransform": "Transformar audio",
"faceRecognition": "Reconocimiento facial",
"voiceRecognition": "Reconocimiento de voz",
@@ -110,6 +110,70 @@
}
},
"backends": {
"lifecycle": {
"navigation": "Backend lifecycle",
"catalog": "Catalog",
"installed": "Installed",
"installedAria": "Installed backends",
"searchPlaceholder": "Search installed backends by name or alias...",
"filterAll": "All",
"filterUser": "User",
"filterSystem": "System",
"filterUpdates": "Updates",
"filterOffline": "Offline nodes",
"variants": "Variants",
"variantsCount": "Variants ({{count}})",
"development": "Development",
"developmentCount": "Development ({{count}})",
"countLabel": "{{visible}} of {{total}}",
"emptyTitle": "No backends installed yet",
"emptyBody": "Install a backend from the Catalog to give this host a runtime for models.",
"noMatches": "No installed backends match the current search and filters.",
"clearFilters": "Clear filters",
"updatesAvailable_one": "{{count}} backend has an update available",
"updatesAvailable_other": "{{count}} backends have updates available",
"upgradeAll": "Upgrade all",
"upgrading": "Upgrading...",
"upgradeAllFailed": "Upgrade failed for {{name}}: {{message}}",
"upgradeAllStarted_one": "Upgrade started for {{count}} backend",
"upgradeAllStarted_other": "Upgrade started for {{count}} backends",
"loadFailed": "Failed to load installed backends: {{message}}",
"reinstall": "Reinstall backend",
"reinstallStarted": "Reinstalling {{name}}...",
"reinstallFailed": "Failed to reinstall: {{message}}",
"upgrade": "Upgrade",
"upgradeTo": "Upgrade to v{{version}}",
"upgradeStarted": "Upgrading {{name}}...",
"upgradeFailed": "Failed to upgrade: {{message}}",
"delete": "Delete",
"deleteBackend": "Delete backend",
"deleteTitle": "Delete Backend",
"deleteMessage": "Delete backend {{name}}?",
"deleteSucceeded": "Deleted backend {{name}}",
"deleteFailed": "Failed to delete backend: {{message}}",
"actionsFor": "Actions for {{name}}",
"protected": "Protected",
"protectedTitle": "System backends are managed outside the gallery",
"allBackends": "All backends",
"version": "Version",
"available": "Available",
"managed": "Managed",
"system": "System",
"gallery": "Gallery",
"installedOn": "Installed on",
"description": "Description",
"source": "Source",
"digest": "Digest",
"installedAt": "Installed",
"working": "Working...",
"updateAvailable": "Update available",
"updateGroup": "Update available",
"installedGroup": "Installed",
"inventoryEyebrow": "Installed runtimes",
"inventoryTitle_one": "{{count}} backend is installed.",
"inventoryTitle_other": "{{count}} backends are installed.",
"inventoryBody": "Select a backend to inspect its source, version, placement, and lifecycle actions."
},
"title": "Manajemen Backend",
"subtitle": "Temukan dan instal backend AI untuk mendukung model Anda"
},
@@ -178,6 +242,20 @@
"clear": "Tidak ada yang membutuhkan perhatian. Backend sudah terbaru, tidak ada operasi yang gagal, dan setiap node sehat.",
"backendUpdate": "Pembaruan tersedia: {{from}} → {{to}}"
},
"capacity": {
"title": "Kapasitas host",
"loading": "Memuat kapasitas host...",
"unavailable": "Kapasitas host tidak tersedia",
"empty": "Tidak ada data kapasitas yang dilaporkan",
"gpus": "{{count}} GPU",
"reclaimer": "Reclaimer aktif",
"used": "Terpakai",
"total": "Total",
"systemRam": "RAM sistem",
"memory": "Memori",
"totalVram": "Total VRAM",
"storage": "Penyimpanan model"
},
"sections": {
"heading": "Bagian-bagian",
"runtime": "Runtime",
@@ -94,8 +94,8 @@
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"models": "Models",
"modelsSummary": "Explore, install, and manage models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
@@ -9,7 +9,7 @@
},
"actions": {
"backTo": "Kembali ke {{page}}",
"system": "Sistem",
"models": "Model",
"templates": "Templat",
"createModel": "Buat Model",
"saveChanges": "Simpan Perubahan",
@@ -1,5 +1,59 @@
{
"title": "Jelajahi",
"lifecycle": {
"title": "Models",
"navLabel": "Model lifecycle",
"views": { "explore": "Explore", "installed": "Installed" },
"filters": { "all": "All", "running": "Running", "idle": "Idle", "disabled": "Disabled", "pinned": "Pinned", "distributed": "Distributed" },
"installed": {
"searchPlaceholder": "Search installed models",
"count": "{{shown}} of {{total}}",
"backToAll": "All installed models",
"eyebrow": "Installed on this host",
"summary": "{{count}} models ready to serve.",
"summaryHint": "Select a model to inspect its runtime state and controls."
},
"states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" },
"actions": {
"load": "Load", "loading": "Loading…", "stop": "Stop", "update": "Update", "updating": "Updating…",
"enable": "Enable model", "disable": "Disable model", "pin": "Pin (prevent idle unload)",
"unpin": "Unpin (allow idle unload)", "edit": "Edit configuration", "logs": "Backend logs",
"delete": "Delete model", "forModel": "Actions for {{model}}",
"open": "Open {{useCase}}", "manageInstallation": "Manage installation"
},
"actionNames": { "load": "load", "stop": "stop", "enable": "enable", "disable": "disable", "pin": "pin", "unpin": "unpin", "delete": "delete", "update": "update" },
"toasts": {
"loaded": "Loaded {{model}}", "stopped": "Stopped {{model}}", "enabled": "Enabled {{model}}",
"disabled": "Disabled {{model}}", "pinned": "Pinned {{model}}", "unpinned": "Unpinned {{model}}",
"deleted": "Deleted {{model}}", "updated": "Models updated"
},
"confirm": {
"stopTitle": "Stop Model", "stopMessage": "Stop model {{model}}?",
"deleteTitle": "Delete Model", "deleteMessage": "Delete model {{model}}? This cannot be undone."
},
"errors": {
"action": "Could not {{action}} {{model}}: {{message}}",
"loadList": "Could not load installed models: {{message}}"
},
"detail": {
"title": "Details", "state": "State", "backend": "Backend", "auto": "Auto", "pinned": "Pinned", "yes": "yes",
"description": "Description", "noDescription": "No gallery description is available for this model.",
"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}}"
},
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
"explore": "Explore models", "import": "Import model", "noMatches": "No installed models match these filters.",
"clear": "Clear filters"
}
},
"title": "Models",
"subtitle": "Telusuri dan instal model AI dari galeri",
"models": "Model",
"recommended": {
@@ -30,7 +30,7 @@
},
"items": {
"home": "Beranda",
"discover": "Jelajahi",
"models": "Models",
"chat": "Obrolan",
"studio": "Studio",
"talk": "Bicara",
@@ -40,7 +40,6 @@
"voices": "Suara",
"jobs": "Pekerjaan",
"operate": "Admin",
"host": "Host",
"audioTransform": "Transformasi Audio",
"faceRecognition": "Pengenalan Wajah",
"voiceRecognition": "Pengenalan Suara",
@@ -110,6 +110,70 @@
}
},
"backends": {
"lifecycle": {
"navigation": "Backend lifecycle",
"catalog": "Catalog",
"installed": "Installed",
"installedAria": "Installed backends",
"searchPlaceholder": "Search installed backends by name or alias...",
"filterAll": "All",
"filterUser": "User",
"filterSystem": "System",
"filterUpdates": "Updates",
"filterOffline": "Offline nodes",
"variants": "Variants",
"variantsCount": "Variants ({{count}})",
"development": "Development",
"developmentCount": "Development ({{count}})",
"countLabel": "{{visible}} of {{total}}",
"emptyTitle": "No backends installed yet",
"emptyBody": "Install a backend from the Catalog to give this host a runtime for models.",
"noMatches": "No installed backends match the current search and filters.",
"clearFilters": "Clear filters",
"updatesAvailable_one": "{{count}} backend has an update available",
"updatesAvailable_other": "{{count}} backends have updates available",
"upgradeAll": "Upgrade all",
"upgrading": "Upgrading...",
"upgradeAllFailed": "Upgrade failed for {{name}}: {{message}}",
"upgradeAllStarted_one": "Upgrade started for {{count}} backend",
"upgradeAllStarted_other": "Upgrade started for {{count}} backends",
"loadFailed": "Failed to load installed backends: {{message}}",
"reinstall": "Reinstall backend",
"reinstallStarted": "Reinstalling {{name}}...",
"reinstallFailed": "Failed to reinstall: {{message}}",
"upgrade": "Upgrade",
"upgradeTo": "Upgrade to v{{version}}",
"upgradeStarted": "Upgrading {{name}}...",
"upgradeFailed": "Failed to upgrade: {{message}}",
"delete": "Delete",
"deleteBackend": "Delete backend",
"deleteTitle": "Delete Backend",
"deleteMessage": "Delete backend {{name}}?",
"deleteSucceeded": "Deleted backend {{name}}",
"deleteFailed": "Failed to delete backend: {{message}}",
"actionsFor": "Actions for {{name}}",
"protected": "Protected",
"protectedTitle": "System backends are managed outside the gallery",
"allBackends": "All backends",
"version": "Version",
"available": "Available",
"managed": "Managed",
"system": "System",
"gallery": "Gallery",
"installedOn": "Installed on",
"description": "Description",
"source": "Source",
"digest": "Digest",
"installedAt": "Installed",
"working": "Working...",
"updateAvailable": "Update available",
"updateGroup": "Update available",
"installedGroup": "Installed",
"inventoryEyebrow": "Installed runtimes",
"inventoryTitle_one": "{{count}} backend is installed.",
"inventoryTitle_other": "{{count}} backends are installed.",
"inventoryBody": "Select a backend to inspect its source, version, placement, and lifecycle actions."
},
"title": "Gestione backend",
"subtitle": "Scopri e installa backend AI per i tuoi modelli"
},
@@ -155,6 +219,20 @@
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"capacity": {
"title": "Capacità host",
"loading": "Caricamento della capacità host...",
"unavailable": "Capacità host non disponibile",
"empty": "Nessun dato sulla capacità segnalato",
"gpus": "{{count}} GPUs",
"reclaimer": "Reclaimer active",
"used": "Used",
"total": "Total",
"systemRam": "System RAM",
"memory": "Memory",
"totalVram": "Total VRAM",
"storage": "Models storage"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
@@ -94,8 +94,8 @@
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"models": "Models",
"modelsSummary": "Explore, install, and manage models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
@@ -1,5 +1,59 @@
{
"title": "Esplora",
"lifecycle": {
"title": "Models",
"navLabel": "Model lifecycle",
"views": { "explore": "Explore", "installed": "Installed" },
"filters": { "all": "All", "running": "Running", "idle": "Idle", "disabled": "Disabled", "pinned": "Pinned", "distributed": "Distributed" },
"installed": {
"searchPlaceholder": "Search installed models",
"count": "{{shown}} of {{total}}",
"backToAll": "All installed models",
"eyebrow": "Installed on this host",
"summary": "{{count}} models ready to serve.",
"summaryHint": "Select a model to inspect its runtime state and controls."
},
"states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" },
"actions": {
"load": "Load", "loading": "Loading…", "stop": "Stop", "update": "Update", "updating": "Updating…",
"enable": "Enable model", "disable": "Disable model", "pin": "Pin (prevent idle unload)",
"unpin": "Unpin (allow idle unload)", "edit": "Edit configuration", "logs": "Backend logs",
"delete": "Delete model", "forModel": "Actions for {{model}}",
"open": "Open {{useCase}}", "manageInstallation": "Manage installation"
},
"actionNames": { "load": "load", "stop": "stop", "enable": "enable", "disable": "disable", "pin": "pin", "unpin": "unpin", "delete": "delete", "update": "update" },
"toasts": {
"loaded": "Loaded {{model}}", "stopped": "Stopped {{model}}", "enabled": "Enabled {{model}}",
"disabled": "Disabled {{model}}", "pinned": "Pinned {{model}}", "unpinned": "Unpinned {{model}}",
"deleted": "Deleted {{model}}", "updated": "Models updated"
},
"confirm": {
"stopTitle": "Stop Model", "stopMessage": "Stop model {{model}}?",
"deleteTitle": "Delete Model", "deleteMessage": "Delete model {{model}}? This cannot be undone."
},
"errors": {
"action": "Could not {{action}} {{model}}: {{message}}",
"loadList": "Could not load installed models: {{message}}"
},
"detail": {
"title": "Details", "state": "State", "backend": "Backend", "auto": "Auto", "pinned": "Pinned", "yes": "yes",
"description": "Description", "noDescription": "No gallery description is available for this model.",
"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}}"
},
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
"explore": "Explore models", "import": "Import model", "noMatches": "No installed models match these filters.",
"clear": "Clear filters"
}
},
"title": "Models",
"subtitle": "Sfoglia e installa modelli AI dalla galleria",
"recommended": {
"title": "Consigliati per il tuo hardware",
@@ -30,7 +30,7 @@
},
"items": {
"home": "Home",
"discover": "Esplora",
"models": "Models",
"chat": "Chat",
"studio": "Studio",
"talk": "Conversazione",
@@ -40,7 +40,6 @@
"voices": "Voci",
"jobs": "Lavori",
"operate": "Amministrazione",
"host": "Host",
"audioTransform": "Trasforma audio",
"faceRecognition": "Riconoscimento volti",
"voiceRecognition": "Riconoscimento vocale",
@@ -110,6 +110,70 @@
}
},
"backends": {
"lifecycle": {
"navigation": "Backend lifecycle",
"catalog": "Catalog",
"installed": "Installed",
"installedAria": "Installed backends",
"searchPlaceholder": "Search installed backends by name or alias...",
"filterAll": "All",
"filterUser": "User",
"filterSystem": "System",
"filterUpdates": "Updates",
"filterOffline": "Offline nodes",
"variants": "Variants",
"variantsCount": "Variants ({{count}})",
"development": "Development",
"developmentCount": "Development ({{count}})",
"countLabel": "{{visible}} of {{total}}",
"emptyTitle": "No backends installed yet",
"emptyBody": "Install a backend from the Catalog to give this host a runtime for models.",
"noMatches": "No installed backends match the current search and filters.",
"clearFilters": "Clear filters",
"updatesAvailable_one": "{{count}} backend has an update available",
"updatesAvailable_other": "{{count}} backends have updates available",
"upgradeAll": "Upgrade all",
"upgrading": "Upgrading...",
"upgradeAllFailed": "Upgrade failed for {{name}}: {{message}}",
"upgradeAllStarted_one": "Upgrade started for {{count}} backend",
"upgradeAllStarted_other": "Upgrade started for {{count}} backends",
"loadFailed": "Failed to load installed backends: {{message}}",
"reinstall": "Reinstall backend",
"reinstallStarted": "Reinstalling {{name}}...",
"reinstallFailed": "Failed to reinstall: {{message}}",
"upgrade": "Upgrade",
"upgradeTo": "Upgrade to v{{version}}",
"upgradeStarted": "Upgrading {{name}}...",
"upgradeFailed": "Failed to upgrade: {{message}}",
"delete": "Delete",
"deleteBackend": "Delete backend",
"deleteTitle": "Delete Backend",
"deleteMessage": "Delete backend {{name}}?",
"deleteSucceeded": "Deleted backend {{name}}",
"deleteFailed": "Failed to delete backend: {{message}}",
"actionsFor": "Actions for {{name}}",
"protected": "Protected",
"protectedTitle": "System backends are managed outside the gallery",
"allBackends": "All backends",
"version": "Version",
"available": "Available",
"managed": "Managed",
"system": "System",
"gallery": "Gallery",
"installedOn": "Installed on",
"description": "Description",
"source": "Source",
"digest": "Digest",
"installedAt": "Installed",
"working": "Working...",
"updateAvailable": "Update available",
"updateGroup": "Update available",
"installedGroup": "Installed",
"inventoryEyebrow": "Installed runtimes",
"inventoryTitle_one": "{{count}} backend is installed.",
"inventoryTitle_other": "{{count}} backends are installed.",
"inventoryBody": "Select a backend to inspect its source, version, placement, and lifecycle actions."
},
"title": "백엔드 관리",
"subtitle": "모델을 구동할 AI 백엔드를 탐색하고 설치합니다"
},
@@ -178,6 +242,20 @@
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"capacity": {
"title": "호스트 용량",
"loading": "호스트 용량 불러오는 중...",
"unavailable": "호스트 용량을 사용할 수 없습니다",
"empty": "보고된 용량 데이터가 없습니다",
"gpus": "{{count}} GPUs",
"reclaimer": "Reclaimer active",
"used": "Used",
"total": "Total",
"systemRam": "System RAM",
"memory": "Memory",
"totalVram": "Total VRAM",
"storage": "Models storage"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
@@ -94,8 +94,8 @@
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"models": "Models",
"modelsSummary": "Explore, install, and manage models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
@@ -1,5 +1,59 @@
{
"title": "둘러보기",
"lifecycle": {
"title": "Models",
"navLabel": "Model lifecycle",
"views": { "explore": "Explore", "installed": "Installed" },
"filters": { "all": "All", "running": "Running", "idle": "Idle", "disabled": "Disabled", "pinned": "Pinned", "distributed": "Distributed" },
"installed": {
"searchPlaceholder": "Search installed models",
"count": "{{shown}} of {{total}}",
"backToAll": "All installed models",
"eyebrow": "Installed on this host",
"summary": "{{count}} models ready to serve.",
"summaryHint": "Select a model to inspect its runtime state and controls."
},
"states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" },
"actions": {
"load": "Load", "loading": "Loading…", "stop": "Stop", "update": "Update", "updating": "Updating…",
"enable": "Enable model", "disable": "Disable model", "pin": "Pin (prevent idle unload)",
"unpin": "Unpin (allow idle unload)", "edit": "Edit configuration", "logs": "Backend logs",
"delete": "Delete model", "forModel": "Actions for {{model}}",
"open": "Open {{useCase}}", "manageInstallation": "Manage installation"
},
"actionNames": { "load": "load", "stop": "stop", "enable": "enable", "disable": "disable", "pin": "pin", "unpin": "unpin", "delete": "delete", "update": "update" },
"toasts": {
"loaded": "Loaded {{model}}", "stopped": "Stopped {{model}}", "enabled": "Enabled {{model}}",
"disabled": "Disabled {{model}}", "pinned": "Pinned {{model}}", "unpinned": "Unpinned {{model}}",
"deleted": "Deleted {{model}}", "updated": "Models updated"
},
"confirm": {
"stopTitle": "Stop Model", "stopMessage": "Stop model {{model}}?",
"deleteTitle": "Delete Model", "deleteMessage": "Delete model {{model}}? This cannot be undone."
},
"errors": {
"action": "Could not {{action}} {{model}}: {{message}}",
"loadList": "Could not load installed models: {{message}}"
},
"detail": {
"title": "Details", "state": "State", "backend": "Backend", "auto": "Auto", "pinned": "Pinned", "yes": "yes",
"description": "Description", "noDescription": "No gallery description is available for this model.",
"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}}"
},
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
"explore": "Explore models", "import": "Import model", "noMatches": "No installed models match these filters.",
"clear": "Clear filters"
}
},
"title": "Models",
"subtitle": "갤러리에서 AI 모델을 둘러보고 설치합니다",
"recommended": {
"title": "하드웨어에 맞는 추천",
@@ -30,7 +30,7 @@
},
"items": {
"home": "홈",
"discover": "둘러보기",
"models": "Models",
"chat": "채팅",
"studio": "스튜디오",
"talk": "대화",
@@ -40,7 +40,6 @@
"voices": "음성",
"jobs": "작업",
"operate": "관리",
"host": "호스트",
"audioTransform": "오디오 변환",
"faceRecognition": "얼굴 인식",
"voiceRecognition": "음성 인식",
@@ -110,6 +110,70 @@
}
},
"backends": {
"lifecycle": {
"navigation": "Backend lifecycle",
"catalog": "Catalog",
"installed": "Installed",
"installedAria": "Installed backends",
"searchPlaceholder": "Search installed backends by name or alias...",
"filterAll": "All",
"filterUser": "User",
"filterSystem": "System",
"filterUpdates": "Updates",
"filterOffline": "Offline nodes",
"variants": "Variants",
"variantsCount": "Variants ({{count}})",
"development": "Development",
"developmentCount": "Development ({{count}})",
"countLabel": "{{visible}} of {{total}}",
"emptyTitle": "No backends installed yet",
"emptyBody": "Install a backend from the Catalog to give this host a runtime for models.",
"noMatches": "No installed backends match the current search and filters.",
"clearFilters": "Clear filters",
"updatesAvailable_one": "{{count}} backend has an update available",
"updatesAvailable_other": "{{count}} backends have updates available",
"upgradeAll": "Upgrade all",
"upgrading": "Upgrading...",
"upgradeAllFailed": "Upgrade failed for {{name}}: {{message}}",
"upgradeAllStarted_one": "Upgrade started for {{count}} backend",
"upgradeAllStarted_other": "Upgrade started for {{count}} backends",
"loadFailed": "Failed to load installed backends: {{message}}",
"reinstall": "Reinstall backend",
"reinstallStarted": "Reinstalling {{name}}...",
"reinstallFailed": "Failed to reinstall: {{message}}",
"upgrade": "Upgrade",
"upgradeTo": "Upgrade to v{{version}}",
"upgradeStarted": "Upgrading {{name}}...",
"upgradeFailed": "Failed to upgrade: {{message}}",
"delete": "Delete",
"deleteBackend": "Delete backend",
"deleteTitle": "Delete Backend",
"deleteMessage": "Delete backend {{name}}?",
"deleteSucceeded": "Deleted backend {{name}}",
"deleteFailed": "Failed to delete backend: {{message}}",
"actionsFor": "Actions for {{name}}",
"protected": "Protected",
"protectedTitle": "System backends are managed outside the gallery",
"allBackends": "All backends",
"version": "Version",
"available": "Available",
"managed": "Managed",
"system": "System",
"gallery": "Gallery",
"installedOn": "Installed on",
"description": "Description",
"source": "Source",
"digest": "Digest",
"installedAt": "Installed",
"working": "Working...",
"updateAvailable": "Update available",
"updateGroup": "Update available",
"installedGroup": "Installed",
"inventoryEyebrow": "Installed runtimes",
"inventoryTitle_one": "{{count}} backend is installed.",
"inventoryTitle_other": "{{count}} backends are installed.",
"inventoryBody": "Select a backend to inspect its source, version, placement, and lifecycle actions."
},
"title": "Gerenciamento de Backends",
"subtitle": "Descubra e instale backends de IA para potencializar seus modelos"
},
@@ -178,6 +242,20 @@
"clear": "Nada requer atenção. Os backends estão atualizados, nenhuma operação falhou e todos os nós estão saudáveis.",
"backendUpdate": "Atualização disponível: {{from}} → {{to}}"
},
"capacity": {
"title": "Capacidade do host",
"loading": "Carregando a capacidade do host...",
"unavailable": "Capacidade do host indisponível",
"empty": "Nenhum dado de capacidade informado",
"gpus": "{{count}} GPUs",
"reclaimer": "Recuperador ativo",
"used": "Usado",
"total": "Total",
"systemRam": "RAM do sistema",
"memory": "Memória",
"totalVram": "VRAM total",
"storage": "Armazenamento de modelos"
},
"sections": {
"heading": "Seções",
"runtime": "Runtime",
@@ -109,8 +109,8 @@
},
"jump": {
"heading": "Continue de onde parou",
"discover": "Descobrir",
"discoverSummary": "Explore a galeria e instale modelos",
"models": "Modelos",
"modelsSummary": "Explore, instale e gerencie modelos",
"create": "Criar",
"createSummary": "Abra uma sessão de chat, imagem ou voz",
"operate": "Operar",
@@ -9,7 +9,7 @@
},
"actions": {
"backTo": "Voltar para {{page}}",
"system": "Sistema",
"models": "Modelos",
"templates": "Modelos",
"createModel": "Criar Modelo",
"saveChanges": "Salvar Alterações",
@@ -1,5 +1,59 @@
{
"title": "Descobrir",
"lifecycle": {
"title": "Models",
"navLabel": "Model lifecycle",
"views": { "explore": "Explore", "installed": "Installed" },
"filters": { "all": "All", "running": "Running", "idle": "Idle", "disabled": "Disabled", "pinned": "Pinned", "distributed": "Distributed" },
"installed": {
"searchPlaceholder": "Search installed models",
"count": "{{shown}} of {{total}}",
"backToAll": "All installed models",
"eyebrow": "Installed on this host",
"summary": "{{count}} models ready to serve.",
"summaryHint": "Select a model to inspect its runtime state and controls."
},
"states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" },
"actions": {
"load": "Load", "loading": "Loading…", "stop": "Stop", "update": "Update", "updating": "Updating…",
"enable": "Enable model", "disable": "Disable model", "pin": "Pin (prevent idle unload)",
"unpin": "Unpin (allow idle unload)", "edit": "Edit configuration", "logs": "Backend logs",
"delete": "Delete model", "forModel": "Actions for {{model}}",
"open": "Open {{useCase}}", "manageInstallation": "Manage installation"
},
"actionNames": { "load": "load", "stop": "stop", "enable": "enable", "disable": "disable", "pin": "pin", "unpin": "unpin", "delete": "delete", "update": "update" },
"toasts": {
"loaded": "Loaded {{model}}", "stopped": "Stopped {{model}}", "enabled": "Enabled {{model}}",
"disabled": "Disabled {{model}}", "pinned": "Pinned {{model}}", "unpinned": "Unpinned {{model}}",
"deleted": "Deleted {{model}}", "updated": "Models updated"
},
"confirm": {
"stopTitle": "Stop Model", "stopMessage": "Stop model {{model}}?",
"deleteTitle": "Delete Model", "deleteMessage": "Delete model {{model}}? This cannot be undone."
},
"errors": {
"action": "Could not {{action}} {{model}}: {{message}}",
"loadList": "Could not load installed models: {{message}}"
},
"detail": {
"title": "Details", "state": "State", "backend": "Backend", "auto": "Auto", "pinned": "Pinned", "yes": "yes",
"description": "Description", "noDescription": "No gallery description is available for this model.",
"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}}"
},
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
"explore": "Explore models", "import": "Import model", "noMatches": "No installed models match these filters.",
"clear": "Clear filters"
}
},
"title": "Modelos",
"subtitle": "Explore e instale modelos de IA da galeria",
"models": "Modelos",
"recommended": {
@@ -30,7 +30,7 @@
},
"items": {
"home": "Início",
"discover": "Descobrir",
"models": "Models",
"chat": "Chat",
"studio": "Studio",
"talk": "Falar",
@@ -40,7 +40,6 @@
"voices": "Vozes",
"jobs": "Trabalhos",
"operate": "Admin",
"host": "Host",
"audioTransform": "Transformação de Áudio",
"faceRecognition": "Reconhecimento Facial",
"voiceRecognition": "Reconhecimento de Voz",
@@ -110,6 +110,70 @@
}
},
"backends": {
"lifecycle": {
"navigation": "Backend lifecycle",
"catalog": "Catalog",
"installed": "Installed",
"installedAria": "Installed backends",
"searchPlaceholder": "Search installed backends by name or alias...",
"filterAll": "All",
"filterUser": "User",
"filterSystem": "System",
"filterUpdates": "Updates",
"filterOffline": "Offline nodes",
"variants": "Variants",
"variantsCount": "Variants ({{count}})",
"development": "Development",
"developmentCount": "Development ({{count}})",
"countLabel": "{{visible}} of {{total}}",
"emptyTitle": "No backends installed yet",
"emptyBody": "Install a backend from the Catalog to give this host a runtime for models.",
"noMatches": "No installed backends match the current search and filters.",
"clearFilters": "Clear filters",
"updatesAvailable_one": "{{count}} backend has an update available",
"updatesAvailable_other": "{{count}} backends have updates available",
"upgradeAll": "Upgrade all",
"upgrading": "Upgrading...",
"upgradeAllFailed": "Upgrade failed for {{name}}: {{message}}",
"upgradeAllStarted_one": "Upgrade started for {{count}} backend",
"upgradeAllStarted_other": "Upgrade started for {{count}} backends",
"loadFailed": "Failed to load installed backends: {{message}}",
"reinstall": "Reinstall backend",
"reinstallStarted": "Reinstalling {{name}}...",
"reinstallFailed": "Failed to reinstall: {{message}}",
"upgrade": "Upgrade",
"upgradeTo": "Upgrade to v{{version}}",
"upgradeStarted": "Upgrading {{name}}...",
"upgradeFailed": "Failed to upgrade: {{message}}",
"delete": "Delete",
"deleteBackend": "Delete backend",
"deleteTitle": "Delete Backend",
"deleteMessage": "Delete backend {{name}}?",
"deleteSucceeded": "Deleted backend {{name}}",
"deleteFailed": "Failed to delete backend: {{message}}",
"actionsFor": "Actions for {{name}}",
"protected": "Protected",
"protectedTitle": "System backends are managed outside the gallery",
"allBackends": "All backends",
"version": "Version",
"available": "Available",
"managed": "Managed",
"system": "System",
"gallery": "Gallery",
"installedOn": "Installed on",
"description": "Description",
"source": "Source",
"digest": "Digest",
"installedAt": "Installed",
"working": "Working...",
"updateAvailable": "Update available",
"updateGroup": "Update available",
"installedGroup": "Installed",
"inventoryEyebrow": "Installed runtimes",
"inventoryTitle_one": "{{count}} backend is installed.",
"inventoryTitle_other": "{{count}} backends are installed.",
"inventoryBody": "Select a backend to inspect its source, version, placement, and lifecycle actions."
},
"title": "后端管理",
"subtitle": "发现并安装为模型提供支持的 AI 后端"
},
@@ -155,6 +219,20 @@
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"capacity": {
"title": "主机容量",
"loading": "正在加载主机容量...",
"unavailable": "主机容量不可用",
"empty": "未报告容量数据",
"gpus": "{{count}} GPUs",
"reclaimer": "Reclaimer active",
"used": "Used",
"total": "Total",
"systemRam": "System RAM",
"memory": "Memory",
"totalVram": "Total VRAM",
"storage": "Models storage"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
@@ -94,8 +94,8 @@
},
"jump": {
"heading": "Jump back in",
"discover": "Discover",
"discoverSummary": "Browse the gallery and install models",
"models": "Models",
"modelsSummary": "Explore, install, and manage models",
"create": "Create",
"createSummary": "Open a chat, image or voice session",
"operate": "Operate",
@@ -1,5 +1,59 @@
{
"title": "发现",
"lifecycle": {
"title": "Models",
"navLabel": "Model lifecycle",
"views": { "explore": "Explore", "installed": "Installed" },
"filters": { "all": "All", "running": "Running", "idle": "Idle", "disabled": "Disabled", "pinned": "Pinned", "distributed": "Distributed" },
"installed": {
"searchPlaceholder": "Search installed models",
"count": "{{shown}} of {{total}}",
"backToAll": "All installed models",
"eyebrow": "Installed on this host",
"summary": "{{count}} models ready to serve.",
"summaryHint": "Select a model to inspect its runtime state and controls."
},
"states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" },
"actions": {
"load": "Load", "loading": "Loading…", "stop": "Stop", "update": "Update", "updating": "Updating…",
"enable": "Enable model", "disable": "Disable model", "pin": "Pin (prevent idle unload)",
"unpin": "Unpin (allow idle unload)", "edit": "Edit configuration", "logs": "Backend logs",
"delete": "Delete model", "forModel": "Actions for {{model}}",
"open": "Open {{useCase}}", "manageInstallation": "Manage installation"
},
"actionNames": { "load": "load", "stop": "stop", "enable": "enable", "disable": "disable", "pin": "pin", "unpin": "unpin", "delete": "delete", "update": "update" },
"toasts": {
"loaded": "Loaded {{model}}", "stopped": "Stopped {{model}}", "enabled": "Enabled {{model}}",
"disabled": "Disabled {{model}}", "pinned": "Pinned {{model}}", "unpinned": "Unpinned {{model}}",
"deleted": "Deleted {{model}}", "updated": "Models updated"
},
"confirm": {
"stopTitle": "Stop Model", "stopMessage": "Stop model {{model}}?",
"deleteTitle": "Delete Model", "deleteMessage": "Delete model {{model}}? This cannot be undone."
},
"errors": {
"action": "Could not {{action}} {{model}}: {{message}}",
"loadList": "Could not load installed models: {{message}}"
},
"detail": {
"title": "Details", "state": "State", "backend": "Backend", "auto": "Auto", "pinned": "Pinned", "yes": "yes",
"description": "Description", "noDescription": "No gallery description is available for this model.",
"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}}"
},
"open": {
"title": "Open", "chat": "Chat", "completion": "Completion", "image": "Image", "video": "Video", "tts": "TTS",
"transcribe": "Transcribe", "sound": "Sound", "face": "Face", "voice": "Voice", "embeddings": "Embeddings",
"rerank": "Rerank", "vad": "VAD", "score": "Score"
},
"empty": {
"title": "No models installed yet", "text": "Explore the gallery or import a model to get started.",
"explore": "Explore models", "import": "Import model", "noMatches": "No installed models match these filters.",
"clear": "Clear filters"
}
},
"title": "Models",
"subtitle": "从模型库浏览和安装 AI 模型",
"recommended": {
"title": "适合你硬件的推荐",
@@ -30,7 +30,7 @@
},
"items": {
"home": "首页",
"discover": "发现",
"models": "Models",
"chat": "聊天",
"studio": "工作室",
"talk": "通话",
@@ -40,7 +40,6 @@
"voices": "声音",
"jobs": "任务",
"operate": "管理",
"host": "主机",
"audioTransform": "音频变换",
"faceRecognition": "人脸识别",
"voiceRecognition": "语音识别",
+61 -57
View File
@@ -1125,6 +1125,12 @@
margin-bottom: var(--spacing-sm);
}
.resource-monitor-badges {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.resource-gpu-list {
display: flex;
flex-direction: column;
@@ -1150,6 +1156,13 @@
color: var(--color-text-primary);
}
.resource-gpu-name--truncate {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-gpu-vendor {
font-size: 0.6875rem;
padding: 2px 6px;
@@ -1158,6 +1171,32 @@
border-radius: var(--radius-sm);
}
.resource-gpu-vendor--dynamic {
color: var(--resource-vendor-color);
background: color-mix(in srgb, var(--resource-vendor-color) 12%, transparent);
}
.resource-gpu-vendor--memory {
color: var(--color-accent);
background: var(--color-accent-light);
}
.resource-meter {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
.resource-meter__value {
min-width: 3em;
color: var(--resource-color);
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 600;
text-align: right;
}
.resource-gpu-stats {
display: flex;
gap: var(--spacing-md);
@@ -1175,11 +1214,26 @@
.resource-bar {
height: 100%;
background: var(--color-primary);
width: var(--resource-width, 0%);
background: var(--resource-color, var(--color-primary));
border-radius: 2px;
transition: width 500ms ease;
}
.resource-summary-row {
display: flex;
justify-content: space-between;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.resource-summary-row__value {
color: var(--color-text-primary);
font-family: var(--font-mono);
}
.resource-bar-ram {
background: var(--color-secondary);
}
@@ -8795,23 +8849,6 @@ button.collapsible-header:focus-visible {
outline-offset: 2px;
}
/* Manage summary marker same .stat-cards layout. Top margin separates the
cards from the System Resources card above (otherwise they sit too close
to the RAM bar) and bottom margin tightens the gap to the tabs below. */
/* Two class names, so this beats the shared .stat-strip shorthand regardless
of which rule the file happens to declare later. The single-class version
was silently losing its top margin to `margin: 0 0 `, leaving the strip
flush against the resources card above it. */
.stat-strip.manage-summary {
margin-top: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
/* .page--app is a flex column whose split view takes flex:1, so a child with
no intrinsic minimum gets shrunk to nothing. The old cards survived only
because .stat-card carried min-height:96px; the figure cells do not, so the
strip has to decline to shrink. */
flex: none;
}
/* Screen-reader-only label, used for table headers whose visual cell needs
no label (kebab-only Actions column, toggle-only Enabled column). The
header still announces correctly to assistive tech without making the
@@ -11945,7 +11982,7 @@ button.collapsible-header:focus-visible {
.ajd-code--error { background: var(--color-error-light); color: var(--color-error); max-height: none; }
/* ==========================================================================
SplitView: the shell shared by Discover, Backends and Host.
SplitView: the shell shared by Models and Backends.
The rail is scanned, the pane answers. The pane has exactly two states -
a zero state with nothing selected and one entity's detail with something
@@ -12485,9 +12522,7 @@ button.collapsible-header:focus-visible {
.split-view--detail .split-view__rail-col { display: none; }
}
/* Compact list rows inside a pane: a state chip, a name, and one number. Used
by the Host status page for "loaded now" and by detail panes for anything
that is a list of facts rather than a table. */
/* Compact list rows inside a pane: a state chip, a name, and one number. */
.rowlist { display: flex; flex-direction: column; gap: 4px; }
.rowline {
@@ -12581,11 +12616,7 @@ button.collapsible-header:focus-visible {
pushes the page taller instead of scrolling inside it. */
min-height: 0;
padding-bottom: var(--spacing-lg);
/* The shell above is overflow:hidden so the document cannot grow, which left
anything taller than the viewport simply unreachable Host has a
resources card, four stat cards and a tab bar above its split, and at any
window height the bottom of the pane fell off with nothing to scroll.
The page scrolls inside the pinned shell; the rail and pane keep their own
/* The page scrolls inside the pinned shell; the rail and pane keep their own
inner scrollers for long lists and long details. */
overflow-y: auto;
}
@@ -12741,7 +12772,7 @@ button.collapsible-header:focus-visible {
}
}
/* Backends: the filters stack in the rail column the way Discover's do. Seven
/* Backends: the filters stack in the rail column the way Models' do. Seven
chips fit at this width, so they need no disclosure. */
.bk-filters {
display: flex;
@@ -12760,9 +12791,6 @@ button.collapsible-header:focus-visible {
gap: 2px;
}
/* Host keeps its resource monitor, summary cards and tabs above the split, so
only what is left after them is pinned. Those are the page's own chrome and
they are read once, unlike the rail and the pane, which are worked in. */
.page--app > .tabs { flex: none; }
.page--app > .view-bar { flex: none; }
@@ -12868,7 +12896,7 @@ button.collapsible-header:focus-visible {
/* Pills are gone. A capsule radius reads as a tag floating on the surface,
which fights a system whose structure is hairlines and square corners and
with chips on Discover, Host, Activity and the biometrics pages, "some pages
with chips on Models, Activity and the biometrics pages, "some pages
have pills" was the actual inconsistency. Round buttons keep their radius:
.lightbox__nav and .home-send-btn are circles, not capsules. */
@@ -13032,11 +13060,7 @@ button.lane:hover {
/* Headline figures on the Operate overview, and the sparklines under them.
Hairline-gridded cells rather than cards: the same 1px grid the split-view
StatGrid uses, so the two read as one system. */
/* A row of headline figures on a hairline grid. Shared by the Operate overview
and the Host page so the two read as one system: same cell, same figure
scale, same tone vocabulary. Replaces Host's shadowed clickable cards, which
were a second dashboard language on a page that already had one. */
.stat-strip,
/* A row of headline figures on a hairline grid. */
.operate-headline {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
@@ -13048,7 +13072,6 @@ button.lane:hover {
overflow: hidden;
}
.stat-strip__cell,
.operate-headline__cell {
background: var(--color-bg-primary);
padding: var(--spacing-sm) var(--spacing-md);
@@ -13056,8 +13079,6 @@ button.lane:hover {
gap: 2px;
}
.stat-strip__label,
.stat-strip__cell dt,
.operate-headline__cell dt {
color: var(--color-text-muted);
font-size: 0.625rem;
@@ -13066,7 +13087,6 @@ button.lane:hover {
text-transform: uppercase;
}
.stat-strip__value,
.operate-headline__value {
margin: 0;
font-family: var(--font-mono);
@@ -13077,27 +13097,11 @@ button.lane:hover {
font-variant-numeric: tabular-nums;
}
.stat-strip__value--primary,
.operate-headline__value--primary { color: var(--color-text-primary); }
.stat-strip__value--success,
.operate-headline__value--success { color: var(--color-success); }
.stat-strip__value--warning,
.operate-headline__value--warning { color: var(--color-warning); }
.stat-strip__value--muted,
.operate-headline__value--muted { color: var(--color-text-tertiary); }
button.stat-strip__cell {
border: 0;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-default);
}
button.stat-strip__cell:hover { background: var(--color-bg-hover); }
button.stat-strip__cell:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; }
.sparkline { display: block; width: 100%; height: 28px; margin-top: 2px; }
.sparkline polyline { stroke: currentColor; }
.sparkline circle { fill: currentColor; }
@@ -1,61 +0,0 @@
// The Host page's headline figures.
//
// These were shadowed, clickable StatCards — a second dashboard language on a
// page that already has a rail, a pane and a tab bar. They are now the same
// hairline figure strip the Operate overview uses, so the two pages read as one
// system rather than as two dashboards that happen to share a console.
//
// Each cell still routes into the tab and filter it describes: a count is worth
// more when it is also the way to the thing counted. Counts are derived by the
// parent — this component stays purely presentational.
export default function ManageSummary({
modelsCount,
backendsCount,
runningCount,
updatesCount,
onCardClick,
}) {
const click = (tab, filter) => onCardClick && onCardClick(tab, filter)
return (
<div className="stat-strip manage-summary">
<Figure
label="Models installed"
value={modelsCount}
onClick={() => click('models', 'all')}
/>
<Figure
label="Backends installed"
value={backendsCount}
onClick={() => click('backends', 'all')}
/>
<Figure
label="Running now"
value={runningCount}
// Tone only when the number means something. A strip where every cell
// is coloured has no emphasis left to spend.
tone={runningCount > 0 ? 'success' : 'muted'}
onClick={() => click('models', 'running')}
/>
<Figure
label="Updates available"
value={updatesCount}
tone={updatesCount > 0 ? 'warning' : 'muted'}
onClick={() => click('backends', updatesCount > 0 ? 'upgradable' : 'all')}
/>
</div>
)
}
// Buttons in a plain container rather than a description list. A <button> is
// not valid inside a <dl>, and <dt>/<dd> are not valid inside a <button>: the
// browser re-parents both and the cells collapse to nothing. These cells are a
// set of controls, so saying so is also the honest markup.
function Figure({ label, value, tone = 'primary', onClick }) {
return (
<button type="button" className="stat-strip__cell" onClick={onClick}>
<span className="stat-strip__label">{label}</span>
<span className={`stat-strip__value stat-strip__value--${tone}`}>{value}</span>
</button>
)
}
@@ -5,7 +5,7 @@ import { useRecommendedModels, isNvfp4Name } from '../hooks/useRecommendedModels
const CONTENT_ID = 'rec-models-content'
// "Recommended for your hardware" at the top of Discover's zero state. Shares
// "Recommended for your hardware" at the top of Models Explore's zero state. Shares
// the hardware-fit ranking with the empty-state starter widget via
// useRecommendedModels.
//
@@ -2,29 +2,70 @@ import { useResources } from '../hooks/useResources'
import { formatBytes, percentColor, vendorColor } from '../utils/format'
export default function ResourceMonitor() {
const { resources, loading } = useResources()
const { resources, loading, error } = useResources()
if (loading || !resources) {
return <div className="resource-monitor text-note">Loading resources...</div>
return (
<ResourceMonitorView
resources={resources}
loading={loading}
unavailable={Boolean(error)}
/>
)
}
export function ResourceMonitorView({
resources,
loading = false,
unavailable = false,
title = 'System Resources',
loadingText = 'Loading resources...',
unavailableText = 'Resource data unavailable',
emptyText = 'No resource data reported',
copy = {},
testId,
}) {
if (loading) {
return <div className="resource-monitor text-note" data-testid={testId}>{loadingText}</div>
}
if (unavailable || !resources) {
return <div className="resource-monitor text-note" data-testid={testId}>{unavailableText}</div>
}
const gpus = resources.gpus || []
const ram = resources.ram || {}
const aggregate = resources.aggregate || {}
const ram = resources.ram || aggregate.ram || {}
const isGpu = resources.type === 'gpu' && gpus.length > 0
const hasRam = ram.total != null || ram.total_bytes != null || ram.used != null || ram.used_bytes != null || ram.usage_percent != null
const hasStorage = resources.storage_size != null
const labels = {
gpuCount: count => `${count} GPUs`,
reclaimer: 'Reclaimer Active',
used: 'Used',
total: 'Total',
systemRam: 'System RAM',
memory: 'Memory',
totalVram: 'Total VRAM',
storage: 'Models storage',
...copy,
}
if (!isGpu && !hasRam && !hasStorage) {
return <div className="resource-monitor text-note" data-testid={testId}>{emptyText}</div>
}
return (
<div className="resource-monitor">
<div className="resource-monitor" data-testid={testId}>
<div className="hstack hstack--between mb-sm">
<h3 className="resource-monitor-title m-0">
<i className="fas fa-chart-bar" /> System Resources
<i className="fas fa-chart-bar" aria-hidden="true" /> {title}
</h3>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', alignItems: 'center' }}>
<div className="resource-monitor-badges">
{isGpu && gpus.length > 1 && (
<span className="badge badge-info">{gpus.length} GPUs</span>
<span className="badge badge-info">{labels.gpuCount(gpus.length)}</span>
)}
{resources.reclaimer_enabled && (
<span className="badge badge-success">Reclaimer Active</span>
<span className="badge badge-success">{labels.reclaimer}</span>
)}
</div>
</div>
@@ -38,59 +79,59 @@ export default function ResourceMonitor() {
return (
<div key={i} className="resource-gpu-card">
<div className="resource-gpu-header">
<span className="resource-gpu-name" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span className="resource-gpu-name resource-gpu-name--truncate">
{gpu.name || `GPU ${i}`}
</span>
{gpu.vendor && (
<span className="resource-gpu-vendor" style={{ background: `${vColor}20`, color: vColor }}>
<span className="resource-gpu-vendor resource-gpu-vendor--dynamic" style={{ '--resource-vendor-color': vColor }}>
{gpu.vendor}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-xs)' }}>
<div className="resource-meter">
<div className="resource-bar-container flex-1">
<div className="resource-bar" style={{ width: `${pct}%`, background: color }} />
<div className="resource-bar" style={{ '--resource-width': `${pct}%`, '--resource-color': color }} />
</div>
<span style={{ fontSize: '0.8125rem', fontWeight: 600, fontFamily: 'var(--font-mono)', color, minWidth: '3em', textAlign: 'right' }}>
<span className="resource-meter__value" style={{ '--resource-color': color }}>
{pct.toFixed(0)}%
</span>
</div>
<div className="resource-gpu-stats">
<span>Used: {formatBytes(gpu.used_vram)}</span>
<span>Total: {formatBytes(gpu.total_vram)}</span>
<span>{labels.used}: {formatBytes(gpu.used_vram)}</span>
<span>{labels.total}: {formatBytes(gpu.total_vram)}</span>
</div>
</div>
)
})}
</div>
) : (
) : hasRam ? (
/* RAM display */
<div className="resource-gpu-card">
<div className="resource-gpu-header">
<span className="resource-gpu-name">System RAM</span>
<span className="resource-gpu-vendor" style={{ background: 'var(--color-accent-light)', color: 'var(--color-accent)' }}>
Memory
<span className="resource-gpu-name">{labels.systemRam}</span>
<span className="resource-gpu-vendor resource-gpu-vendor--memory">
{labels.memory}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-xs)' }}>
<div className="resource-meter">
<div className="resource-bar-container flex-1">
<div className="resource-bar" style={{ width: `${ram.usage_percent || 0}%`, background: percentColor(ram.usage_percent || 0) }} />
<div className="resource-bar" style={{ '--resource-width': `${ram.usage_percent || 0}%`, '--resource-color': percentColor(ram.usage_percent || 0) }} />
</div>
<span style={{ fontSize: '0.8125rem', fontWeight: 600, fontFamily: 'var(--font-mono)', color: percentColor(ram.usage_percent || 0), minWidth: '3em', textAlign: 'right' }}>
<span className="resource-meter__value" style={{ '--resource-color': percentColor(ram.usage_percent || 0) }}>
{(ram.usage_percent || 0).toFixed(0)}%
</span>
</div>
<div className="resource-gpu-stats">
<span>Used: {formatBytes(ram.used || 0)}</span>
<span>Total: {formatBytes(ram.total || 0)}</span>
<span>{labels.used}: {formatBytes(ram.used ?? ram.used_bytes ?? 0)}</span>
<span>{labels.total}: {formatBytes(ram.total ?? ram.total_bytes ?? 0)}</span>
</div>
</div>
)}
) : null}
{/* Aggregate for multi-GPU */}
{isGpu && aggregate.gpu_count > 1 && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginTop: 'var(--spacing-sm)', display: 'flex', justifyContent: 'space-between' }}>
<span>Total VRAM</span>
<div className="resource-summary-row">
<span>{labels.totalVram}</span>
<span className="text-mono">
{formatBytes(aggregate.used_memory)} / {formatBytes(aggregate.total_memory)} ({aggregate.usage_percent?.toFixed(1)}%)
</span>
@@ -99,9 +140,9 @@ export default function ResourceMonitor() {
{/* Storage */}
{resources.storage_size != null && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginTop: 'var(--spacing-sm)', display: 'flex', justifyContent: 'space-between' }}>
<span>Models storage</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-primary)' }}>
<div className="resource-summary-row">
<span>{labels.storage}</span>
<span className="resource-summary-row__value">
{formatBytes(resources.storage_size)}
</span>
</div>
@@ -15,11 +15,7 @@ const SECTIONS_KEY = 'localai_sidebar_sections'
const topItems = [
{ path: '/app', icon: 'fas fa-home', labelKey: 'items.home' },
// "Discover" rather than "Models": the installed-models view lives under
// Host, so a bare "Models" here would name two different pages. The compass
// replaces a download arrow because the page is now browsed before it is
// installed from.
{ path: '/app/models', icon: 'fas fa-compass', labelKey: 'items.discover', adminOnly: true },
{ path: '/app/models', icon: 'fas fa-cubes', labelKey: 'items.models', adminOnly: true },
]
// Create stays inline (frequent, one-click creative destinations). The Build
@@ -57,7 +53,7 @@ function NavItem({ item, onClose, collapsed }) {
onTouchStart={preload}
title={collapsed ? label : undefined}
>
<i className={`${item.icon} nav-icon`} />
<i className={`${item.icon} nav-icon`} aria-hidden="true" />
<span className="nav-label">{label}</span>
</NavLink>
)
@@ -259,7 +255,7 @@ export default function Sidebar({ isOpen, onClose }) {
onTouchStart={() => preloadRoute(target)}
title={collapsed ? label : undefined}
>
<i className={`${config.icon} nav-icon`} />
<i className={`${config.icon} nav-icon`} aria-hidden="true" />
<span className="nav-label">{label}</span>
{config.groups.some(g => g.items.some(i => i.badge === 'operations')) && activeOps > 0 && (
<span className={`nav-badge${failedOps > 0 ? ' nav-badge--error' : ''}`}>
@@ -92,7 +92,6 @@ export const operateConsole = {
items: [
{ path: '/app/users', icon: 'fas fa-users', labelKey: 'items.users', adminOnly: true, authOnly: true },
{ path: '/app/middleware', icon: 'fas fa-shield-halved', labelKey: 'items.middleware', adminOnly: true },
{ path: '/app/manage', icon: 'fas fa-desktop', labelKey: 'items.host', adminOnly: true, signal: 'host' },
{ path: '/app/settings', icon: 'fas fa-cog', labelKey: 'items.settings', adminOnly: true },
{ href: '/swagger/index.html', icon: 'fas fa-code', labelKey: 'items.api', external: true, adminOnly: true },
],
@@ -1,4 +1,4 @@
import { useRef } from 'react'
import { useEffect, useRef } from 'react'
// EntityRail is the scannable half of SplitView: one line per entity, grouped
// while browsing and flat while searching.
@@ -33,6 +33,25 @@ export default function EntityRail({
busy = false,
}) {
const railRef = useRef(null)
const lastSelectedIdRef = useRef(null)
// Narrow layouts hide the rail while detail is selected. When Back (or the
// browser Back button) clears URL-owned selection, return keyboard focus to
// the row that opened the detail instead of dropping it on <body>.
useEffect(() => {
if (selectedId) {
lastSelectedIdRef.current = selectedId
return undefined
}
const id = lastSelectedIdRef.current
if (!id) return undefined
const frame = window.requestAnimationFrame(() => {
const item = railRef.current?.querySelector(`[data-entity="${CSS.escape(id)}"]`)
item?.scrollIntoView({ block: 'nearest' })
item?.focus({ preventScroll: true })
})
return () => window.cancelAnimationFrame(frame)
}, [selectedId])
// Up/Down moves the selection so the pane can be stepped through without
// going back to the mouse.
@@ -68,7 +87,12 @@ export default function EntityRail({
// Without this every entry is its own stop, so tabbing past a forty-entry
// rail to reach the pane is forty keystrokes.
const firstId = items[0]?.id
const tabbableId = items.some(i => i.id === selectedId) ? selectedId : firstId
const lastSelectedId = lastSelectedIdRef.current
const tabbableId = items.some(i => i.id === selectedId)
? selectedId
: items.some(i => i.id === lastSelectedId)
? lastSelectedId
: firstId
const renderItem = (item) => (
<RailItem
@@ -1,7 +1,7 @@
// SplitView is the shell three admin surfaces share: a rail you scan on the
// SplitView is the shell the resource lifecycle pages share: a rail you scan on the
// left, a pane that answers on the right.
//
// It exists because Discover, Backends and Host all had the same defect - an
// It exists because the old Models and Backends tables had the same defect - an
// eight-column table over a click-to-expand row - and the fix is the same
// shape every time. What differs between them is what the rail lists and what
// the pane says when nothing is selected, so those are the props.
@@ -40,6 +40,7 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
const [upgrades, setUpgrades] = useState({})
const [nodes, setNodes] = useState([])
const [resources, setResources] = useState(null)
const [resourcesLoaded, setResourcesLoaded] = useState(false)
const [traces, setTraces] = useState(null)
const [installed, setInstalled] = useState({ backends: null, models: null })
const { operations } = useOperations()
@@ -63,6 +64,7 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
setUpgrades(u && typeof u === 'object' ? u : {})
setNodes(Array.isArray(n) ? n : (n?.nodes || []))
setResources(r)
setResourcesLoaded(true)
setTraces(tr)
setInstalled({
backends: Array.isArray(bi) ? bi.length : (bi?.backends?.length ?? null),
@@ -106,6 +108,8 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
upgrades,
nodes,
resources,
resourcesLoading: !resourcesLoaded,
resourcesUnavailable: resourcesLoaded && !resources,
operations,
traces,
installed,
@@ -120,7 +124,7 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
usage: traces?.total ? compact(traces.total) : null,
},
}
}, [upgrades, nodes, resources, operations, traces, installed])
}, [upgrades, nodes, resources, resourcesLoaded, operations, traces, installed])
return (
<OperateSummaryContext.Provider value={value}>
+1 -1
View File
@@ -257,7 +257,7 @@ export default function AgentJobs() {
args: ["--flag"]`}</pre>
</div>
<div className="hstack hstack--center">
<button className="btn btn-primary" onClick={() => navigate('/app/manage')}>
<button className="btn btn-primary" onClick={() => navigate('/app/models?view=installed')}>
<i className="fas fa-cog" /> Manage Models
</button>
<a className="btn btn-secondary" href="https://localai.io/features/agents/" target="_blank" rel="noopener noreferrer">
+1 -1
View File
@@ -426,7 +426,7 @@ export default function BackendLogs() {
<h2 className="empty-state-title">No model selected</h2>
<p className="empty-state-text">
View backend logs for a specific model from the{' '}
<Link to="/app/manage" className="text-primary">System page</Link>.
<Link to="/app/models?view=installed" className="text-primary">Installed Models page</Link>.
</p>
</div>
</div>
+154 -48
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import { Link, useOutletContext, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { backendsApi, nodesApi } from '../utils/api'
import { useDebouncedCallback } from '../hooks/useDebounce'
@@ -7,7 +7,6 @@ import React from 'react'
import { useOperations } from '../hooks/useOperations'
import { useDistributedMode } from '../hooks/useDistributedMode'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import { renderMarkdown, stripMarkdown } from '../utils/markdown'
import { safeHref } from '../utils/url'
import ConfirmDialog from '../components/ConfirmDialog'
@@ -21,18 +20,22 @@ import DetailHeader from '../components/split/DetailHeader'
import StatGrid from '../components/split/StatGrid'
import { useResources } from '../hooks/useResources'
import { ENTITY_GROUPS, groupForEntity } from '../utils/entityGroups'
import InstalledBackends from './InstalledBackends'
export default function Backends() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { t } = useTranslation('admin')
const [searchParams, setSearchParams] = useSearchParams()
const activeView = searchParams.get('view') === 'installed' ? 'installed' : 'catalog'
const { operations } = useOperations()
const { resources } = useResources()
const { enabled: distributedEnabled, nodes: clusterNodes, refetch: refetchNodes } = useDistributedMode()
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('')
const [search, setSearch] = useState(() => searchParams.get('q') || '')
const [filter, setFilter] = useState(() => {
const state = searchParams.get('state') || ''
return ['', 'chat', 'image', 'video', 'tts', 'transcript', 'vision'].includes(state) ? state : ''
})
const [sortBy, setSortBy] = useState('name')
const [sortOrder, setSortOrder] = useState('asc')
const [page, setPage] = useState(1)
@@ -42,7 +45,7 @@ export default function Backends() {
const [manualName, setManualName] = useState('')
const [manualAlias, setManualAlias] = useState('')
// Which backend the pane is showing, or null for the host page. In the URL
// for the same reasons as Discover: a backend is linkable, and Back leaves
// for the same reasons as Models Explore: a backend is linkable, and Back leaves
// the detail rather than the page.
// True once any listing has come back. Distinguishes a cold start, which has
// nothing to keep on screen, from a refetch, which does.
@@ -51,12 +54,15 @@ export default function Backends() {
const [allBackends, setAllBackends] = useState([])
const [upgrades, setUpgrades] = useState({})
const [upgradingAll, setUpgradingAll] = useState(false)
const [showAllBackends, setShowAllBackends] = useState(false)
const [showDevelopment, setShowDevelopment] = useState(false)
const [showAllBackends, setShowAllBackends] = useState(() => searchParams.get('show_all') === '1')
const [showDevelopment, setShowDevelopment] = useState(() => searchParams.get('development') === '1')
const [preferDevLoaded, setPreferDevLoaded] = useState(false)
const [pickerBackend, setPickerBackend] = useState(null)
const [pickerInitialSelection, setPickerInitialSelection] = useState([])
const [splitMenuOpen, setSplitMenuOpen] = useState(false)
const [catalogErrors, setCatalogErrors] = useState({})
const [catalogGlobalError, setCatalogGlobalError] = useState('')
const [manualError, setManualError] = useState('')
// Anchor for the split-button chevron. One pane, so one anchor.
const splitMenuAnchorRef = useRef(null)
@@ -66,6 +72,34 @@ export default function Backends() {
// per-node endpoint.
const selectedName = searchParams.get('backend')
const hrefForView = (view) => {
const next = new URLSearchParams(searchParams)
next.set('view', view)
return `/app/backends?${next.toString()}`
}
const updateUrlParam = useCallback((key, value, defaultValue = '') => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (!value || value === defaultValue) next.delete(key)
else next.set(key, value)
return next
}, { replace: true })
}, [setSearchParams])
useEffect(() => {
if (activeView !== 'catalog') return
const nextSearch = searchParams.get('q') || ''
const requestedState = searchParams.get('state') || ''
const nextFilter = ['', 'chat', 'image', 'video', 'tts', 'transcript', 'vision'].includes(requestedState)
? requestedState
: ''
setSearch(nextSearch)
setFilter(nextFilter)
setShowAllBackends(searchParams.get('show_all') === '1')
setShowDevelopment(searchParams.get('development') === '1')
}, [activeView, searchParams])
// Selection is a URL edit that preserves everything else in the query, so it
// composes with the target-node scope rather than clobbering it.
const selectBackend = useCallback((name) => {
@@ -110,7 +144,7 @@ export default function Backends() {
try {
setLoading(true)
const params = { page: 1, items: 9999, sort: sortBy, order: sortOrder }
if (search) params.term = search
if (activeView === 'catalog' && search) params.term = search
const data = await backendsApi.list(params)
const list = Array.isArray(data?.backends) ? data.backends : Array.isArray(data) ? data : []
setAllBackends(list)
@@ -126,11 +160,13 @@ export default function Backends() {
loadedOnce.current = true
setLoading(false)
}
}, [search, sortBy, sortOrder, addToast])
}, [activeView, search, sortBy, sortOrder, addToast])
const debouncedFetch = useDebouncedCallback(fetchBackends)
useEffect(() => {
fetchBackends()
}, [sortBy, sortOrder])
debouncedFetch()
}, [debouncedFetch, fetchBackends])
// Re-fetch when operations change (install/delete completion)
useEffect(() => {
@@ -179,12 +215,10 @@ export default function Backends() {
const totalPages = Math.max(1, Math.ceil(filteredBackends.length / ITEMS_PER_PAGE))
const backends = filteredBackends.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE)
const debouncedFetch = useDebouncedCallback(() => fetchBackends())
const handleSearch = (value) => {
setSearch(value)
updateUrlParam('q', value)
setPage(1)
debouncedFetch()
}
const handleSort = (col) => {
@@ -198,6 +232,7 @@ export default function Backends() {
}
const handleInstall = async (id) => {
setCatalogErrors(current => ({ ...current, [id]: '' }))
try {
await backendsApi.install(id)
} catch (err) {
@@ -213,7 +248,7 @@ export default function Backends() {
return
}
}
addToast(`Install failed: ${err.message}`, 'error')
setCatalogErrors(current => ({ ...current, [id]: `Install failed: ${err.message}` }))
}
}
@@ -223,6 +258,7 @@ export default function Backends() {
// surface progress; no need to await completion here.
const handleInstallOnTarget = async (id) => {
if (!targetNode) return
setCatalogErrors(current => ({ ...current, [id]: '' }))
try {
await nodesApi.installBackend(targetNode.id, id)
addToast(`Installing ${id} on ${targetNode.name}...`, 'info')
@@ -231,14 +267,17 @@ export default function Backends() {
// tracks the actual progress until completion.
setTimeout(() => { fetchBackends(); refetchNodes() }, 1200)
} catch (err) {
addToast(`Install dispatch failed on ${targetNode.name}: ${err.message}`, 'error')
setCatalogErrors(current => ({
...current,
[id]: `Install dispatch failed on ${targetNode.name}: ${err.message}`,
}))
}
}
const openPicker = (b, initialSelection = []) => {
setPickerBackend(b)
setPickerInitialSelection(initialSelection)
setSplitMenuFor(null)
setSplitMenuOpen(false)
}
// Returns the IDs of nodes that don't yet have this backend installed.
@@ -260,23 +299,25 @@ export default function Backends() {
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
setCatalogErrors(current => ({ ...current, [id]: '' }))
try {
await backendsApi.delete(id)
addToast(`Deleting ${id}...`, 'info')
setTimeout(fetchBackends, 1000)
} catch (err) {
addToast(`Delete failed: ${err.message}`, 'error')
setCatalogErrors(current => ({ ...current, [id]: `Delete failed: ${err.message}` }))
}
},
})
}
const handleUpgrade = async (id) => {
setCatalogErrors(current => ({ ...current, [id]: '' }))
try {
await backendsApi.upgrade(id)
addToast(`Upgrading ${id}...`, 'info')
} catch (err) {
addToast(`Upgrade failed: ${err.message}`, 'error')
setCatalogErrors(current => ({ ...current, [id]: `Upgrade failed: ${err.message}` }))
}
}
@@ -284,13 +325,14 @@ export default function Backends() {
const names = Object.keys(upgrades)
if (names.length === 0) return
setUpgradingAll(true)
setCatalogGlobalError('')
try {
for (const name of names) {
await backendsApi.upgrade(name)
}
addToast(`Upgrading ${names.length} backend${names.length > 1 ? 's' : ''}...`, 'info')
} catch (err) {
addToast(`Upgrade failed: ${err.message}`, 'error')
setCatalogGlobalError(`Upgrade failed: ${err.message}`)
} finally {
setUpgradingAll(false)
}
@@ -298,7 +340,8 @@ export default function Backends() {
const handleManualInstall = async (e) => {
e.preventDefault()
if (!manualUri.trim()) { addToast('Please enter a URI', 'warning'); return }
setManualError('')
if (!manualUri.trim()) { setManualError('Please enter a URI'); return }
try {
if (targetNode) {
// Target-node mode: route the manual install to the per-node endpoint
@@ -325,7 +368,7 @@ export default function Backends() {
setManualAlias('')
setShowManualInstall(false)
} catch (err) {
addToast(`Install failed: ${err.message}`, 'error')
setManualError(`Install failed: ${err.message}`)
}
}
@@ -335,8 +378,20 @@ export default function Backends() {
return operations.find(op => op.name === backend.name || op.name === backend.id) || null
}
const handleToggleAllBackends = () => { setShowAllBackends(v => !v); setPage(1) }
const handleToggleDev = () => { setShowDevelopment(v => !v); setPage(1) }
const handleToggleAllBackends = () => {
setShowAllBackends(value => {
updateUrlParam('show_all', value ? '' : '1')
return !value
})
setPage(1)
}
const handleToggleDev = () => {
setShowDevelopment(value => {
updateUrlParam('development', value ? '' : '1')
return !value
})
setPage(1)
}
const FILTERS = [
{ key: '', label: 'All', icon: 'fa-layer-group' },
@@ -350,11 +405,49 @@ export default function Backends() {
return (
<div className="page page--wide page--app">
<div className="view-bar">
<h1 className="view-bar__title">{t('backends.title')}</h1>
{activeView === 'catalog' && (
<span className="view-bar__count">{backends.length} of {allBackends.length}</span>
)}
{activeView === 'catalog' && (
<div className="view-bar__actions">
{Object.keys(upgrades).length > 0 && (
<button className="btn btn-primary btn-sm" onClick={handleUpgradeAll} disabled={upgradingAll}>
<i className={`fas ${upgradingAll ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} /> Upgrade all ({Object.keys(upgrades).length})
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setShowManualInstall(!showManualInstall)}>
<i className={`fas ${showManualInstall ? 'fa-chevron-up' : 'fa-plus'}`} /> Manual Install
</button>
</div>
)}
</div>
<nav className="tabs" aria-label={t('backends.lifecycle.navigation')}>
<Link
className={`tab ${activeView === 'catalog' ? 'tab-active' : ''}`}
to={hrefForView('catalog')}
aria-current={activeView === 'catalog' ? 'page' : undefined}
>
<i className="fas fa-layer-group icon-before" aria-hidden="true" />
{t('backends.lifecycle.catalog')}
</Link>
<Link
className={`tab ${activeView === 'installed' ? 'tab-active' : ''}`}
to={hrefForView('installed')}
aria-current={activeView === 'installed' ? 'page' : undefined}
>
<i className="fas fa-server icon-before" aria-hidden="true" />
{t('backends.lifecycle.installed')}
</Link>
</nav>
{/* Target-node banner: when this gallery is scoped to one node via
?target=<id> (entered from /app/nodes), show the scope clearly and
give a fast way to clear it. Visually a primary-tinted strip so the
user knows they're in a special mode without it feeling alarming. */}
{targetNode && (
{activeView === 'catalog' && targetNode && (
<div className="card bk-notice tone-primary mb-md">
<i className="fas fa-bullseye" style={{ color: 'var(--color-primary)' }} />
<span className="bk-notice__text">
@@ -367,21 +460,24 @@ export default function Backends() {
</div>
)}
{/* Header */}
<div className="view-bar">
<h1 className="view-bar__title">{t('backends.title')}</h1>
<span className="view-bar__count">{backends.length} of {allBackends.length}</span>
<div className="view-bar__actions">
{Object.keys(upgrades).length > 0 && (
<button className="btn btn-primary btn-sm" onClick={handleUpgradeAll} disabled={upgradingAll}>
<i className={`fas ${upgradingAll ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} /> Upgrade all ({Object.keys(upgrades).length})
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setShowManualInstall(!showManualInstall)}>
<i className={`fas ${showManualInstall ? 'fa-chevron-up' : 'fa-plus'}`} /> Manual Install
</button>
{activeView === 'installed' ? (
<InstalledBackends
addToast={addToast}
catalogBackends={allBackends}
distributedEnabled={distributedEnabled}
operations={operations}
upgrades={upgrades}
selectedName={selectedName}
onSelect={selectBackend}
/>
) : (
<>
{catalogGlobalError && (
<div className="attention-callout attention-callout--error mb-md" role="alert">
<i className="fas fa-circle-exclamation" aria-hidden="true" />
<span>{catalogGlobalError}</span>
</div>
</div>
)}
{/* Upgrade Banner */}
{Object.keys(upgrades).length > 0 && (
@@ -426,10 +522,11 @@ export default function Backends() {
<i className="fas fa-download" /> Install
</button>
</div>
{manualError && <p className="form-error" role="alert">{manualError}</p>}
</form>
)}
{/* The gallery, as a rail and a pane. Same shell as Discover, because it
{/* The gallery, as a rail and a pane. Same shell as Models Explore, because it
is the same defect: a seven-column table whose expand-row was the
only place the repository, licence, tags and links could go. */}
{loading && !loadedOnce.current ? (
@@ -454,7 +551,7 @@ export default function Backends() {
<button
key={f.key}
className={`filter-btn ${filter === f.key ? 'active' : ''}`}
onClick={() => { setFilter(f.key); setPage(1) }}
onClick={() => { setFilter(f.key); updateUrlParam('state', f.key); setPage(1) }}
>
<i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />
{f.label}
@@ -558,12 +655,13 @@ export default function Backends() {
<i className="fas fa-rotate" /> Reinstall
</button>
<button className="btn btn-danger btn-sm" onClick={async () => {
setCatalogErrors(current => ({ ...current, [name]: '' }))
try {
await nodesApi.deleteBackend(targetNode.id, name)
addToast(`Removed ${b.name} from ${targetNode.name}`, 'success')
setTimeout(() => { fetchBackends(); refetchNodes() }, 600)
} catch (err) {
addToast(`Remove failed: ${err.message}`, 'error')
setCatalogErrors(current => ({ ...current, [name]: `Remove failed: ${err.message}` }))
}
}} title={`Remove from ${targetNode.name}`}>
<i className="fas fa-trash" /> Remove
@@ -576,15 +674,14 @@ export default function Backends() {
)
) : b.installed ? (
<>
{upgrade ? (
{upgrade && (
<button className="btn btn-primary btn-sm" onClick={() => handleUpgrade(name)} title={`Upgrade to ${upgrade.available_version ? 'v' + upgrade.available_version : 'latest'}`}>
<i className="fas fa-arrow-up" /> Upgrade
</button>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(name)} title="Reinstall">
<i className="fas fa-rotate" /> Reinstall
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(name)} title="Reinstall">
<i className="fas fa-rotate" /> Reinstall
</button>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(name)} title="Delete">
<i className="fas fa-trash" /> Delete
</button>
@@ -623,6 +720,13 @@ export default function Backends() {
}
/>
{catalogErrors[name] && (
<div className="attention-callout attention-callout--error" role="alert">
<i className="fas fa-circle-exclamation" aria-hidden="true" />
<span>{catalogErrors[name]}</span>
</div>
)}
<StatGrid
stats={[
{ label: 'Installed', value: b.installed ? (b.version ? `v${b.version}` : 'yes') : 'no', tone: b.installed ? 'ok' : undefined },
@@ -720,6 +824,8 @@ export default function Backends() {
initialSelection={pickerInitialSelection}
addToast={addToast}
/>
</>
)}
</div>
)
}
+3 -3
View File
@@ -442,7 +442,7 @@ export default function Home() {
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
<i className="fas fa-download" aria-hidden="true" /> {t('quickLinks.browseGallery')}
</button>
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
<button className="home-link-btn" onClick={() => navigate('/app/models?view=installed')}>
<i className="fas fa-desktop" aria-hidden="true" /> {t('quickLinks.installedModels')}
</button>
<button className="home-link-btn" onClick={() => navigate('/app/import-model')}>
@@ -463,8 +463,8 @@ export default function Home() {
<ul className="lanes lanes--jump reveal-stagger">
<li style={staggerStyle(0)}>
<button type="button" className="lane" onClick={() => navigate('/app/models')}>
<span className="lane__tag">{t('jump.discover')}</span>
<span className="lane__desc">{t('jump.discoverSummary')}</span>
<span className="lane__tag">{t('jump.models')}</span>
<span className="lane__desc">{t('jump.modelsSummary')}</span>
<span className="lane__go" aria-hidden="true"></span>
</button>
</li>
+2 -2
View File
@@ -243,7 +243,7 @@ export default function ImportModel() {
setIsSubmitting(false)
setJob(null)
addToast(t('toasts.imported'), 'success')
navigate('/app/manage')
navigate('/app/models?view=installed')
return
}
if (data.error || (data.message && data.message.startsWith('error:'))) {
@@ -375,7 +375,7 @@ export default function ImportModel() {
try {
await modelsApi.importConfig(yamlContent, 'application/x-yaml')
addToast(t('toasts.importedYaml'), 'success')
navigate('/app/manage')
navigate('/app/models?view=installed')
} catch (err) {
addToast(t('toasts.importFailed', { message: err.message }), 'error')
} finally {
@@ -0,0 +1,544 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { backendsApi } from '../utils/api'
import ActionMenu from '../components/ActionMenu'
import ConfirmDialog from '../components/ConfirmDialog'
import FilterBar from '../components/FilterBar'
import GalleryLoader from '../components/GalleryLoader'
import NodeDistributionChip from '../components/NodeDistributionChip'
import SplitView from '../components/split/SplitView'
import EntityRail from '../components/split/EntityRail'
import DetailHeader from '../components/split/DetailHeader'
import StatGrid from '../components/split/StatGrid'
import { stripMarkdown } from '../utils/markdown'
const STATE_GROUPS = [
{ id: 'update', labelKey: 'backends.lifecycle.updateGroup', icon: 'fa-arrow-up' },
{ id: 'installed', labelKey: 'backends.lifecycle.installedGroup', icon: 'fa-check' },
]
const VALID_STATES = new Set(['all', 'user', 'system', 'upgradable', 'offline'])
export default function InstalledBackends({
addToast,
catalogBackends,
distributedEnabled,
operations,
upgrades,
selectedName,
onSelect,
}) {
const { t } = useTranslation('admin')
const [searchParams, setSearchParams] = useSearchParams()
const [backends, setBackends] = useState([])
const [loading, setLoading] = useState(true)
const loadedOnce = useRef(false)
const [pending, setPending] = useState(() => new Set())
const [errors, setErrors] = useState({})
const [globalError, setGlobalError] = useState('')
const [confirmDialog, setConfirmDialog] = useState(null)
const [upgradingAll, setUpgradingAll] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState(() => new Set())
const query = searchParams.get('q') || ''
const requestedState = searchParams.get('state') || 'all'
const state = VALID_STATES.has(requestedState) ? requestedState : 'all'
const showVariants = searchParams.get('show_all') === '1'
const showDevelopment = searchParams.get('development') === '1'
const updateParam = useCallback((key, value, defaultValue = '') => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (!value || value === defaultValue) next.delete(key)
else next.set(key, value)
return next
}, { replace: true })
}, [setSearchParams])
const fetchBackends = useCallback(async () => {
try {
setLoading(true)
const data = await backendsApi.listInstalled()
setBackends(Array.isArray(data) ? data : [])
setGlobalError('')
} catch (err) {
setBackends([])
setGlobalError(t('backends.lifecycle.loadFailed', { message: err.message }))
} finally {
loadedOnce.current = true
setLoading(false)
}
}, [t])
useEffect(() => { fetchBackends() }, [fetchBackends, operations.length])
const catalogByName = new Map(catalogBackends.map(backend => [backend.name || backend.id, backend]))
const flagsFor = (backend) => {
const catalog = catalogByName.get(backend.Name)
return {
variant: !!catalog?.isAlias,
development: !!catalog?.isDevelopment,
}
}
const visibleBase = backends.filter(backend => {
const flags = flagsFor(backend)
if (flags.variant && !showVariants) return false
if (flags.development && !showDevelopment) return false
return true
})
const hiddenVariantCount = showVariants ? 0 : backends.filter(backend => flagsFor(backend).variant).length
const hiddenDevelopmentCount = showDevelopment ? 0 : backends.filter(backend => flagsFor(backend).development).length
const offlineFor = (backend) => (backend.Nodes || backend.nodes || []).some(node => {
const status = node.node_status || node.NodeStatus
return status && status !== 'healthy' && status !== 'draining'
})
const passesState = (backend) => {
if (state === 'user') return !backend.IsSystem
if (state === 'system') return !!backend.IsSystem
if (state === 'upgradable') return !!upgrades[backend.Name]
if (state === 'offline') return offlineFor(backend)
return true
}
const normalizedQuery = query.trim().toLowerCase()
const visibleBackends = visibleBase.filter(backend => passesState(backend) && (
!normalizedQuery
|| backend.Name.toLowerCase().includes(normalizedQuery)
|| (backend.Metadata?.alias || '').toLowerCase().includes(normalizedQuery)
|| (backend.Metadata?.meta_backend_for || '').toLowerCase().includes(normalizedQuery)
))
const selectedBackend = selectedName
? backends.find(backend => backend.Name === selectedName) || null
: null
const isProcessing = useCallback((name) => pending.has(name) || operations.some(operation => (
operation.name === name && !operation.completed && !operation.error
)), [operations, pending])
const withPending = async (name, action) => {
setPending(current => new Set(current).add(name))
setErrors(current => ({ ...current, [name]: '' }))
try {
await action()
} finally {
setPending(current => {
const next = new Set(current)
next.delete(name)
return next
})
}
}
const handleReinstall = async (name) => {
try {
await withPending(name, () => backendsApi.install(name))
addToast(t('backends.lifecycle.reinstallStarted', { name }), 'info')
} catch (err) {
setErrors(current => ({
...current,
[name]: t('backends.lifecycle.reinstallFailed', { message: err.message }),
}))
}
}
const handleUpgrade = async (name) => {
try {
await withPending(name, () => backendsApi.upgrade(name))
addToast(t('backends.lifecycle.upgradeStarted', { name }), 'info')
} catch (err) {
setErrors(current => ({
...current,
[name]: t('backends.lifecycle.upgradeFailed', { message: err.message }),
}))
}
}
const handleUpgradeAll = async () => {
const names = Object.keys(upgrades)
if (names.length === 0) return
setUpgradingAll(true)
setGlobalError('')
try {
const failures = []
for (const name of names) {
try {
await backendsApi.upgrade(name)
} catch (err) {
failures.push(t('backends.lifecycle.upgradeAllFailed', { name, message: err.message }))
}
}
if (failures.length > 0) {
setGlobalError(failures.join(' '))
return
}
addToast(t('backends.lifecycle.upgradeAllStarted', { count: names.length }), 'info')
} finally {
setUpgradingAll(false)
}
}
const handleDelete = (name) => {
setConfirmDialog({
title: t('backends.lifecycle.deleteTitle'),
message: t('backends.lifecycle.deleteMessage', { name }),
confirmLabel: t('backends.lifecycle.delete'),
onConfirm: async () => {
setConfirmDialog(null)
setErrors(current => ({ ...current, [name]: '' }))
try {
await backendsApi.deleteInstalled(name)
addToast(t('backends.lifecycle.deleteSucceeded', { name }), 'success')
onSelect(null)
fetchBackends()
} catch (err) {
setErrors(current => ({
...current,
[name]: t('backends.lifecycle.deleteFailed', { message: err.message }),
}))
}
},
})
}
const toggleGroup = (id) => {
setCollapsedGroups(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const filters = [
{ key: 'all', label: t('backends.lifecycle.filterAll'), icon: 'fa-layer-group', count: visibleBase.length },
{ key: 'user', label: t('backends.lifecycle.filterUser'), icon: 'fa-download', count: visibleBase.filter(backend => !backend.IsSystem).length },
{ key: 'system', label: t('backends.lifecycle.filterSystem'), icon: 'fa-shield-alt', count: visibleBase.filter(backend => backend.IsSystem).length },
...(Object.keys(upgrades).length > 0 ? [{
key: 'upgradable',
label: t('backends.lifecycle.filterUpdates'),
icon: 'fa-arrow-up',
count: visibleBase.filter(backend => upgrades[backend.Name]).length,
}] : []),
...(distributedEnabled && visibleBase.some(offlineFor) ? [{
key: 'offline',
label: t('backends.lifecycle.filterOffline'),
icon: 'fa-exclamation-circle',
count: visibleBase.filter(offlineFor).length,
}] : []),
]
if (loading && !loadedOnce.current) return <GalleryLoader />
return (
<>
{Object.keys(upgrades).length > 0 && (
<div className="upgrade-banner">
<div className="upgrade-banner__text">
<i className="fas fa-arrow-up" aria-hidden="true" />
<span>{t('backends.lifecycle.updatesAvailable', { count: Object.keys(upgrades).length })}</span>
</div>
<div className="upgrade-banner__actions">
<button className="btn btn-primary btn-sm" onClick={handleUpgradeAll} disabled={upgradingAll}>
<i className={`fas ${upgradingAll ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} aria-hidden="true" />
{upgradingAll ? t('backends.lifecycle.upgrading') : t('backends.lifecycle.upgradeAll')}
</button>
</div>
</div>
)}
{globalError && (
<div className="attention-callout attention-callout--error mb-md" role="alert">
<i className="fas fa-circle-exclamation" aria-hidden="true" />
<span>{globalError}</span>
</div>
)}
{backends.length === 0 ? (
<div className="empty-state empty-state--page">
<div className="empty-state-icon"><i className="fas fa-server" /></div>
<h2 className="empty-state-title">{t('backends.lifecycle.emptyTitle')}</h2>
<p className="empty-state-text">{t('backends.lifecycle.emptyBody')}</p>
</div>
) : (
<>
<FilterBar
search={query}
onSearchChange={value => updateParam('q', value)}
searchPlaceholder={t('backends.lifecycle.searchPlaceholder')}
filters={filters}
activeFilter={state}
onFilterChange={value => updateParam('state', value, 'all')}
toggles={[
{
key: 'variants',
label: hiddenVariantCount > 0
? t('backends.lifecycle.variantsCount', { count: hiddenVariantCount })
: t('backends.lifecycle.variants'),
icon: 'fa-cubes',
checked: showVariants,
onChange: () => updateParam('show_all', showVariants ? '' : '1'),
},
{
key: 'development',
label: hiddenDevelopmentCount > 0
? t('backends.lifecycle.developmentCount', { count: hiddenDevelopmentCount })
: t('backends.lifecycle.development'),
icon: 'fa-flask',
checked: showDevelopment,
onChange: () => updateParam('development', showDevelopment ? '' : '1'),
},
]}
/>
{visibleBackends.length === 0 ? (
<div className="empty-state">
<i className="fas fa-filter" />
<p>{t('backends.lifecycle.noMatches')}</p>
<button className="btn btn-ghost btn-sm" onClick={() => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.delete('q')
next.delete('state')
return next
}, { replace: true })
}}>
{t('backends.lifecycle.clearFilters')}
</button>
</div>
) : (
<SplitView
testId="backends-installed"
detail={!!selectedBackend}
rail={(
<EntityRail
items={visibleBackends.map(backend => railItemForBackend(backend, upgrades, isProcessing, t))}
groups={STATE_GROUPS.map(group => ({ ...group, label: t(group.labelKey) }))}
grouped={!query.trim()}
collapsedGroups={collapsedGroups}
onToggleGroup={toggleGroup}
busy={loading}
selectedId={selectedName}
onSelect={onSelect}
countLabel={t('backends.lifecycle.countLabel', { visible: visibleBackends.length, total: backends.length })}
ariaLabel={t('backends.lifecycle.installedAria')}
testId="backends-installed-rail"
/>
)}
pane={selectedBackend ? (
<InstalledBackendDetail
backend={selectedBackend}
catalog={catalogByName.get(selectedBackend.Name)}
upgrade={upgrades[selectedBackend.Name]}
processing={isProcessing(selectedBackend.Name)}
error={errors[selectedBackend.Name]}
distributedEnabled={distributedEnabled}
onBack={() => onSelect(null)}
onUpgrade={handleUpgrade}
onReinstall={handleReinstall}
onDelete={handleDelete}
t={t}
/>
) : (
<InstalledOverview backends={backends} upgrades={upgrades} onSelect={onSelect} t={t} />
)}
/>
)}
</>
)}
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}
message={confirmDialog?.message}
confirmLabel={confirmDialog?.confirmLabel}
danger
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
</>
)
}
function railItemForBackend(backend, upgrades, isProcessing, t) {
const version = backend.Metadata?.version || backend.Version
const upgrade = upgrades[backend.Name]
let groupId = 'installed'
let stripe = 'idle'
let meta = version ? `v${version}` : t('backends.lifecycle.installed')
let metaTone
if (isProcessing(backend.Name)) {
meta = t('backends.lifecycle.working')
metaTone = 'busy'
} else if (upgrade) {
groupId = 'update'
stripe = 'err'
meta = upgrade.available_version
? `v${version} → v${upgrade.available_version}`
: t('backends.lifecycle.updateAvailable')
metaTone = 'warn'
}
return { id: backend.Name, name: backend.Name, icon: 'fa-server', meta, metaTone, stripe, groupId }
}
function InstalledBackendDetail({
backend,
catalog,
upgrade,
processing,
error,
distributedEnabled,
onBack,
onUpgrade,
onReinstall,
onDelete,
t,
}) {
const name = backend.Name
const version = backend.Metadata?.version || backend.Version
const nodes = backend.Nodes || backend.nodes || []
return (
<div className="detail-pane">
<DetailHeader
testId="backends-installed"
icon="fa-server"
name={name}
lede={catalog?.description ? stripMarkdown(catalog.description).slice(0, 220) : null}
ledeTitle={catalog?.description ? stripMarkdown(catalog.description) : null}
onBack={onBack}
backLabel={t('backends.lifecycle.allBackends')}
actions={backend.IsSystem ? (
<span className="badge" title={t('backends.lifecycle.protectedTitle')}>
<i className="fas fa-lock" /> {t('backends.lifecycle.protected')}
</span>
) : (
<>
{upgrade && (
<button className="btn btn-primary btn-sm" onClick={() => onUpgrade(name)} disabled={processing}>
<i className="fas fa-arrow-up" />
{upgrade.available_version
? t('backends.lifecycle.upgradeTo', { version: upgrade.available_version })
: t('backends.lifecycle.upgrade')}
</button>
)}
<ActionMenu
ariaLabel={t('backends.lifecycle.actionsFor', { name })}
triggerLabel={t('backends.lifecycle.actionsFor', { name })}
items={[
{
key: 'reinstall',
icon: 'fa-rotate',
label: t('backends.lifecycle.reinstall'),
onClick: () => onReinstall(name),
disabled: processing,
},
{ divider: true },
{
key: 'delete',
icon: 'fa-trash',
label: t('backends.lifecycle.deleteBackend'),
danger: true,
onClick: () => onDelete(name),
},
]}
/>
</>
)}
/>
{error && (
<div className="attention-callout attention-callout--error" role="alert">
<i className="fas fa-circle-exclamation" aria-hidden="true" />
<span>{error}</span>
</div>
)}
<StatGrid stats={[
{ label: t('backends.lifecycle.version'), value: version ? `v${version}` : '—' },
upgrade ? {
label: t('backends.lifecycle.available'),
value: upgrade.available_version ? `v${upgrade.available_version}` : t('backends.lifecycle.updateAvailable'),
tone: 'warn',
} : null,
{
label: t('backends.lifecycle.managed'),
value: backend.IsSystem ? t('backends.lifecycle.system') : t('backends.lifecycle.gallery'),
},
]} />
{distributedEnabled && nodes.length > 0 && (
<div>
<span className="detail-pane__label">{t('backends.lifecycle.installedOn')}</span>
<NodeDistributionChip nodes={nodes} context="backends" compactThreshold={20} />
</div>
)}
<div className="bk-detail">
<table className="bk-detail__table">
<tbody>
{catalog?.description && (
<tr>
<td className="bk-detail__label">{t('backends.lifecycle.description')}</td>
<td className="bk-detail__value">{stripMarkdown(catalog.description)}</td>
</tr>
)}
{backend.Metadata?.uri && (
<tr>
<td className="bk-detail__label">{t('backends.lifecycle.source')}</td>
<td className="bk-detail__value cell-mono wrap-anywhere">{backend.Metadata.uri}</td>
</tr>
)}
{backend.Metadata?.digest && (
<tr>
<td className="bk-detail__label">{t('backends.lifecycle.digest')}</td>
<td className="bk-detail__value cell-mono wrap-anywhere">{backend.Metadata.digest}</td>
</tr>
)}
{backend.Metadata?.installed_at && (
<tr>
<td className="bk-detail__label">{t('backends.lifecycle.installedAt')}</td>
<td className="bk-detail__value cell-mono">{backend.Metadata.installed_at}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
function InstalledOverview({ backends, upgrades, onSelect, t }) {
const staleNames = Object.keys(upgrades)
return (
<div className="zero-pane">
<div className="zero-pane__hero">
<span className="zero-pane__eyebrow">{t('backends.lifecycle.inventoryEyebrow')}</span>
<h2 className="zero-pane__title">{t('backends.lifecycle.inventoryTitle', { count: backends.length })}</h2>
<p className="zero-pane__text">{t('backends.lifecycle.inventoryBody')}</p>
</div>
<StatGrid stats={[
{ label: t('backends.lifecycle.installed'), value: backends.length },
{ label: t('backends.lifecycle.filterUpdates'), value: staleNames.length, tone: staleNames.length ? 'warn' : undefined },
{ label: t('backends.lifecycle.system'), value: backends.filter(backend => backend.IsSystem).length },
]} />
{staleNames.length > 0 && (
<div className="zero-pane__shelf">
<div className="zero-pane__shelf-head">
<h3 className="zero-pane__shelf-title">{t('backends.lifecycle.filterUpdates')}</h3>
</div>
<div className="rowlist">
{staleNames.map(name => (
<button className="rowline" key={name} onClick={() => onSelect(name)}>
<span className="badge badge-warning"><i className="fas fa-arrow-up icon-tiny" /> {t('backends.lifecycle.updateAvailable')}</span>
<span>{name}</span>
</button>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,635 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import ActionMenu from '../components/ActionMenu'
import ConfirmDialog from '../components/ConfirmDialog'
import FilterBar from '../components/FilterBar'
import GalleryLoader from '../components/GalleryLoader'
import NodeDistributionChip from '../components/NodeDistributionChip'
import SplitView from '../components/split/SplitView'
import EntityRail from '../components/split/EntityRail'
import DetailHeader from '../components/split/DetailHeader'
import StatGrid from '../components/split/StatGrid'
import { useModels } from '../hooks/useModels'
import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment'
import { useOperations } from '../hooks/useOperations'
import { backendControlApi, modelsApi, nodesApi, systemApi } from '../utils/api'
import { renderMarkdown, stripMarkdown } from '../utils/markdown'
import { safeHref } from '../utils/url'
import {
CAP_CHAT, CAP_COMPLETION, CAP_IMAGE, CAP_VIDEO, CAP_TTS,
CAP_TRANSCRIPT, CAP_SOUND_GENERATION, CAP_FACE_RECOGNITION,
CAP_SPEAKER_RECOGNITION, CAP_EMBEDDINGS, CAP_RERANK,
CAP_VAD, CAP_SCORE,
} from '../utils/capabilities'
const USE_CASES = [
{ cap: CAP_CHAT, labelKey: 'chat', route: id => `/app/chat/${encodeURIComponent(id)}` },
{ cap: CAP_COMPLETION, labelKey: 'completion', route: id => `/app/chat/${encodeURIComponent(id)}`, hideIf: CAP_CHAT },
{ cap: CAP_IMAGE, labelKey: 'image', route: id => `/app/image/${encodeURIComponent(id)}` },
{ cap: CAP_VIDEO, labelKey: 'video', route: id => `/app/video/${encodeURIComponent(id)}` },
{ cap: CAP_TTS, labelKey: 'tts', route: id => `/app/tts/${encodeURIComponent(id)}` },
{ cap: CAP_TRANSCRIPT, labelKey: 'transcribe', route: () => '/app/talk' },
{ cap: CAP_SOUND_GENERATION, labelKey: 'sound', route: id => `/app/sound/${encodeURIComponent(id)}` },
{ cap: CAP_FACE_RECOGNITION, labelKey: 'face', route: id => `/app/face/${encodeURIComponent(id)}` },
{ cap: CAP_SPEAKER_RECOGNITION, labelKey: 'voice', route: id => `/app/voice/${encodeURIComponent(id)}` },
{ cap: CAP_EMBEDDINGS, labelKey: 'embeddings' },
{ cap: CAP_RERANK, labelKey: 'rerank' },
{ cap: CAP_VAD, labelKey: 'vad' },
{ cap: CAP_SCORE, labelKey: 'score' },
]
export function modelUseCases(model) {
const capabilities = Array.isArray(model?.capabilities) ? model.capabilities : []
return USE_CASES.filter(item => (
capabilities.includes(item.cap) && !(item.hideIf && capabilities.includes(item.hideIf))
))
}
const MODEL_STATE_GROUPS = [
{ id: 'running', labelKey: 'running', icon: 'fa-circle-play' },
{ id: 'idle', labelKey: 'idle', icon: 'fa-pause' },
{ id: 'disabled', labelKey: 'disabled', icon: 'fa-ban' },
]
export function ModelLifecycleDetailShell({
testId,
icon,
name,
lede,
ledeTitle,
onBack,
backLabel,
warning,
actions,
stats,
error,
children,
}) {
return (
<div className="detail-pane">
<DetailHeader
testId={testId}
icon={icon}
name={name}
lede={lede}
ledeTitle={ledeTitle}
onBack={onBack}
backLabel={backLabel}
warning={warning}
actions={actions}
/>
{Array.isArray(stats) && <StatGrid stats={stats} />}
{error && (
<div className="attention-callout attention-callout--error" role="alert">
<span><i className="fas fa-circle-exclamation icon-before" aria-hidden="true" />{error}</span>
</div>
)}
{children}
</div>
)
}
export default function InstalledModels({
addToast,
query,
state,
selectedName,
onQueryChange,
onStateChange,
onSelect,
}) {
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation('models')
const { models, loading, error: loadError, refetch } = useModels()
const { enrichModel } = useGalleryEnrichment()
const { operations } = useOperations()
const [loadedModelIds, setLoadedModelIds] = useState(() => new Set())
const [aliasTargets, setAliasTargets] = useState({})
const [distributedMode, setDistributedMode] = useState(false)
const [pendingActions, setPendingActions] = useState(() => new Set())
const [actionErrors, setActionErrors] = useState({})
const [confirmDialog, setConfirmDialog] = useState(null)
const [collapsedGroups, setCollapsedGroups] = useState(() => new Set())
const loadedOnce = useRef(false)
const fetchLoadedModels = useCallback(async () => {
try {
const info = await systemApi.info()
const loaded = Array.isArray(info?.loaded_models) ? info.loaded_models : []
setLoadedModelIds(new Set(loaded.map(model => model.id)))
} catch {
setLoadedModelIds(new Set())
}
}, [])
const fetchAliases = useCallback(async () => {
try {
const aliases = await modelsApi.listAliases()
const next = {}
for (const alias of Array.isArray(aliases) ? aliases : []) next[alias.name] = alias.target
setAliasTargets(next)
} catch {
setAliasTargets({})
}
}, [])
useEffect(() => {
fetchLoadedModels()
fetchAliases()
nodesApi.list().then(() => setDistributedMode(true)).catch(() => setDistributedMode(false))
}, [fetchAliases, fetchLoadedModels])
useEffect(() => {
if (!distributedMode) return
const interval = setInterval(() => {
refetch()
fetchLoadedModels()
}, 10000)
return () => clearInterval(interval)
}, [distributedMode, fetchLoadedModels, refetch])
useEffect(() => {
if (!loading) loadedOnce.current = true
}, [loading])
useEffect(() => {
refetch()
fetchLoadedModels()
}, [operations.length, fetchLoadedModels, refetch])
const isRunning = useCallback(model => (
!model.disabled && (
loadedModelIds.has(model.id) ||
(Array.isArray(model.loaded_on) && model.loaded_on.length > 0)
)
), [loadedModelIds])
const filters = [
{ key: 'all', label: t('lifecycle.filters.all'), icon: 'fa-layer-group' },
{ key: 'running', label: t('lifecycle.filters.running'), icon: 'fa-circle-play' },
{ key: 'idle', label: t('lifecycle.filters.idle'), icon: 'fa-pause' },
{ key: 'disabled', label: t('lifecycle.filters.disabled'), icon: 'fa-ban' },
{ key: 'pinned', label: t('lifecycle.filters.pinned'), icon: 'fa-thumbtack' },
{ key: 'distributed', label: t('lifecycle.filters.distributed'), icon: 'fa-server' },
]
const matchesState = model => {
if (state === 'running') return isRunning(model)
if (state === 'idle') return !model.disabled && !isRunning(model)
if (state === 'disabled') return !!model.disabled
if (state === 'pinned') return !!model.pinned
if (state === 'distributed') return Array.isArray(model.loaded_on) && model.loaded_on.length > 0
return true
}
const normalizedQuery = query.trim().toLowerCase()
const visibleModels = models.filter(model => (
matchesState(model) && (
!normalizedQuery ||
model.id.toLowerCase().includes(normalizedQuery) ||
(model.backend || '').toLowerCase().includes(normalizedQuery)
)
))
const selectedModel = selectedName
? models.find(model => model.id === selectedName) || null
: null
const toggleGroup = useCallback(id => {
setCollapsedGroups(previous => {
const next = new Set(previous)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const setPending = (name, pending) => {
setPendingActions(previous => {
const next = new Set(previous)
if (pending) next.add(name)
else next.delete(name)
return next
})
}
const runAction = async (modelName, action, request, successMessage) => {
setPending(modelName, true)
setActionErrors(previous => ({ ...previous, [modelName]: null }))
try {
await request()
if (successMessage) addToast(successMessage, 'success')
refetch()
await fetchLoadedModels()
return true
} catch (err) {
setActionErrors(previous => ({
...previous,
[modelName]: t('lifecycle.errors.action', { action, model: modelName, message: err.message }),
}))
return false
} finally {
setPending(modelName, false)
}
}
const handleLoad = modelName => runAction(
modelName,
t('lifecycle.actionNames.load'),
() => backendControlApi.load({ model: modelName }),
t('lifecycle.toasts.loaded', { model: modelName }),
)
const handleStop = modelName => {
setConfirmDialog({
title: t('lifecycle.confirm.stopTitle'),
message: t('lifecycle.confirm.stopMessage', { model: modelName }),
confirmLabel: t('lifecycle.actions.stop'),
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
await runAction(
modelName,
t('lifecycle.actionNames.stop'),
() => backendControlApi.shutdown({ model: modelName }),
t('lifecycle.toasts.stopped', { model: modelName }),
)
},
})
}
const handleToggleState = (modelName, disabled) => {
const operation = disabled ? 'enable' : 'disable'
return runAction(
modelName,
t(`lifecycle.actionNames.${operation}`),
() => modelsApi.toggleState(modelName, operation),
t(`lifecycle.toasts.${operation}d`, { model: modelName }),
)
}
const handleTogglePinned = (modelName, pinned) => {
const operation = pinned ? 'unpin' : 'pin'
return runAction(
modelName,
t(`lifecycle.actionNames.${operation}`),
() => modelsApi.togglePinned(modelName, operation),
t(`lifecycle.toasts.${operation}ned`, { model: modelName }),
)
}
const handleDelete = modelName => {
setConfirmDialog({
title: t('lifecycle.confirm.deleteTitle'),
message: t('lifecycle.confirm.deleteMessage', { model: modelName }),
confirmLabel: t('lifecycle.actions.delete'),
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
const deleted = await runAction(
modelName,
t('lifecycle.actionNames.delete'),
() => modelsApi.deleteByName(modelName),
t('lifecycle.toasts.deleted', { model: modelName }),
)
if (deleted) onSelect(null)
},
})
}
const handleReload = () => runAction(
'models',
t('lifecycle.actionNames.update'),
modelsApi.reload,
t('lifecycle.toasts.updated'),
)
const railItems = visibleModels.map(model => {
const running = isRunning(model)
let groupId = 'idle'
let stripe = 'idle'
let meta = t('lifecycle.states.idle')
let metaTone
if (model.disabled) {
groupId = 'disabled'
stripe = 'off'
meta = t('lifecycle.states.disabled')
} else if (pendingActions.has(model.id)) {
meta = t('lifecycle.states.working')
metaTone = 'busy'
} else if (running) {
groupId = 'running'
stripe = 'run'
meta = t('lifecycle.states.running')
metaTone = 'ok'
}
return { id: model.id, name: model.id, icon: 'fa-brain', groupId, stripe, meta, metaTone }
})
const groups = MODEL_STATE_GROUPS.map(group => ({
id: group.id,
icon: group.icon,
label: t(`lifecycle.filters.${group.labelKey}`),
}))
const selectedPane = selectedModel ? (() => {
const enriched = enrichModel(selectedModel.id)
const useCases = modelUseCases(selectedModel)
const running = isRunning(selectedModel)
const pending = pendingActions.has(selectedModel.id)
return (
<ModelLifecycleDetailShell
testId="installed-models"
icon="fa-brain"
name={selectedModel.id}
lede={enriched?.description ? stripMarkdown(enriched.description).slice(0, 220) : null}
ledeTitle={enriched?.description ? stripMarkdown(enriched.description) : null}
onBack={() => onSelect(null)}
backLabel={t('lifecycle.installed.backToAll')}
error={actionErrors[selectedModel.id]}
stats={[
{
label: t('lifecycle.detail.state'),
value: selectedModel.disabled
? t('lifecycle.states.disabled')
: running
? t('lifecycle.states.running')
: t('lifecycle.states.idle'),
tone: running ? 'ok' : undefined,
},
{ label: t('lifecycle.detail.backend'), value: selectedModel.backend || t('lifecycle.detail.auto') },
selectedModel.pinned
? { label: t('lifecycle.detail.pinned'), value: t('lifecycle.detail.yes'), tone: 'warn' }
: null,
]}
actions={(
<>
{!selectedModel.disabled && !running && (
<button className="btn btn-primary btn-sm" onClick={() => handleLoad(selectedModel.id)} disabled={pending}>
<i className={`fas ${pending ? 'fa-spinner fa-spin' : 'fa-bolt'}`} aria-hidden="true" />
{pending ? t('lifecycle.actions.loading') : t('lifecycle.actions.load')}
</button>
)}
{running && (
<button className="btn btn-secondary btn-sm" onClick={() => handleStop(selectedModel.id)} disabled={pending}>
<i className="fas fa-stop" aria-hidden="true" /> {t('lifecycle.actions.stop')}
</button>
)}
<ActionMenu
ariaLabel={t('lifecycle.actions.forModel', { model: selectedModel.id })}
triggerLabel={t('lifecycle.actions.forModel', { model: selectedModel.id })}
items={[
{
key: 'toggle',
icon: selectedModel.disabled ? 'fa-toggle-on' : 'fa-toggle-off',
label: selectedModel.disabled ? t('lifecycle.actions.enable') : t('lifecycle.actions.disable'),
onClick: () => handleToggleState(selectedModel.id, selectedModel.disabled),
disabled: pending,
},
{
key: 'pin',
icon: 'fa-thumbtack',
label: selectedModel.pinned ? t('lifecycle.actions.unpin') : t('lifecycle.actions.pin'),
onClick: () => handleTogglePinned(selectedModel.id, selectedModel.pinned),
disabled: pending || !!selectedModel.disabled,
},
{
key: 'edit',
icon: 'fa-pen-to-square',
label: t('lifecycle.actions.edit'),
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(selectedModel.id)}`, {
state: fromState(location, t('lifecycle.title')),
}),
},
{
key: 'logs',
icon: 'fa-terminal',
label: t('lifecycle.actions.logs'),
onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(selectedModel.id)}`),
},
{ divider: true },
{
key: 'delete',
icon: 'fa-trash',
label: t('lifecycle.actions.delete'),
danger: true,
onClick: () => handleDelete(selectedModel.id),
},
]}
/>
</>
)}
>
{(aliasTargets[selectedModel.id] || selectedModel.source === 'registry-only') && (
<div className="badge-row">
{selectedModel.source === 'registry-only' && (
<span className="badge badge-warning" title={t('lifecycle.detail.adoptedHint')}>
<i className="fas fa-ghost" /> {t('lifecycle.detail.adopted')}
</span>
)}
{aliasTargets[selectedModel.id] && (
<span className="badge badge-info" title={t('lifecycle.detail.aliasTitle', { target: aliasTargets[selectedModel.id] })}>
<i className="fas fa-arrow-right-arrow-left" /> {t('lifecycle.detail.alias', { target: aliasTargets[selectedModel.id] })}
</span>
)}
</div>
)}
{useCases.length > 0 && (
<div>
<span className="detail-pane__label">{t('lifecycle.open.title')}</span>
<div className="badge-row">
{useCases.map(useCase => useCase.route ? (
<button
key={useCase.cap}
type="button"
className="badge badge-info badge-link"
onClick={() => navigate(useCase.route(selectedModel.id))}
>
{t(`lifecycle.open.${useCase.labelKey}`)}
</button>
) : (
<span key={useCase.cap} className="badge">{t(`lifecycle.open.${useCase.labelKey}`)}</span>
))}
</div>
</div>
)}
<InstalledModelDetail
model={selectedModel}
enriched={enriched}
distributedMode={distributedMode}
t={t}
/>
</ModelLifecycleDetailShell>
)
})() : (
<div className="zero-pane">
<div className="zero-pane__hero">
<span className="zero-pane__eyebrow">{t('lifecycle.installed.eyebrow')}</span>
<h2 className="zero-pane__title">{t('lifecycle.installed.summary', { count: models.length })}</h2>
<p className="zero-pane__text">{t('lifecycle.installed.summaryHint')}</p>
</div>
</div>
)
return (
<>
<FilterBar
search={query}
onSearchChange={onQueryChange}
searchPlaceholder={t('lifecycle.installed.searchPlaceholder')}
filters={filters}
activeFilter={state}
onFilterChange={onStateChange}
rightSlot={(
<button className="btn btn-secondary btn-sm" onClick={handleReload} disabled={pendingActions.has('models')}>
<i className={`fas ${pendingActions.has('models') ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
{pendingActions.has('models') ? t('lifecycle.actions.updating') : t('lifecycle.actions.update')}
</button>
)}
/>
{loadError && (
<div className="attention-callout attention-callout--error" role="alert">
<span>{t('lifecycle.errors.loadList', { message: loadError })}</span>
</div>
)}
{actionErrors.models && (
<div className="attention-callout attention-callout--error" role="alert">
<span><i className="fas fa-circle-exclamation icon-before" aria-hidden="true" />{actionErrors.models}</span>
</div>
)}
{loading && !loadedOnce.current ? (
<GalleryLoader />
) : models.length === 0 ? (
<div className="empty-state empty-state--page">
<div className="empty-state-icon"><i className="fas fa-brain" /></div>
<h2 className="empty-state-title">{t('lifecycle.empty.title')}</h2>
<p className="empty-state-text">{t('lifecycle.empty.text')}</p>
<div className="empty-state__actions">
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/models')}>
<i className="fas fa-store" /> {t('lifecycle.empty.explore')}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> {t('lifecycle.empty.import')}
</button>
</div>
</div>
) : visibleModels.length === 0 ? (
<div className="empty-state">
<i className="fas fa-filter" />
<p>{t('lifecycle.empty.noMatches')}</p>
<button className="btn btn-ghost btn-sm" onClick={() => { onQueryChange(''); onStateChange('all') }}>
{t('lifecycle.empty.clear')}
</button>
</div>
) : (
<SplitView
testId="installed-models"
detail={!!selectedModel}
rail={(
<EntityRail
items={railItems}
groups={groups}
grouped={!query.trim() && state === 'all'}
collapsedGroups={collapsedGroups}
onToggleGroup={toggleGroup}
busy={loading}
selectedId={selectedName}
onSelect={onSelect}
countLabel={t('lifecycle.installed.count', { shown: visibleModels.length, total: models.length })}
ariaLabel={t('lifecycle.views.installed')}
testId="installed-models-rail"
/>
)}
pane={selectedPane}
/>
)}
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}
message={confirmDialog?.message}
confirmLabel={confirmDialog?.confirmLabel}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
</>
)
}
function InstalledModelDetail({ model, enriched, distributedMode, t }) {
const description = enriched?.description
const license = enriched?.license
const tags = Array.isArray(enriched?.tags) ? enriched.tags : []
const urls = Array.isArray(enriched?.urls) ? enriched.urls : []
const files = Array.isArray(enriched?.additionalFiles)
? enriched.additionalFiles
: Array.isArray(enriched?.files)
? enriched.files
: []
return (
<div className="resource-row__detail">
<h3><i className="fas fa-circle-info" /> {t('lifecycle.detail.title')}</h3>
<dl className="resource-row__detail-grid">
<dt>{t('lifecycle.detail.description')}</dt>
<dd>
{description ? (
<div
className="resource-row__detail-md markdown-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(description) }}
/>
) : (
<span className="cell-muted">{t('lifecycle.detail.noDescription')}</span>
)}
</dd>
<dt>{t('lifecycle.detail.backend')}</dt>
<dd><span className="badge badge-info">{model.backend || t('lifecycle.detail.auto')}</span></dd>
{license && (<>
<dt>{t('lifecycle.detail.license')}</dt>
<dd>{license}</dd>
</>)}
{tags.length > 0 && (<>
<dt>{t('lifecycle.detail.tags')}</dt>
<dd><div className="badge-row">{tags.map(tag => <span key={tag} className="badge badge-info">{tag}</span>)}</div></dd>
</>)}
{urls.length > 0 && (<>
<dt>{t('lifecycle.detail.links')}</dt>
<dd>
<div className="stack stack--xs">
{urls.map(url => (
<a key={url} href={safeHref(url)} target="_blank" rel="noopener noreferrer" className="badge badge-info badge-link">
<i className="fas fa-external-link-alt icon-before text-xs" />{url}
</a>
))}
</div>
</dd>
</>)}
{distributedMode && Array.isArray(model.loaded_on) && model.loaded_on.length > 0 && (<>
<dt>{t('lifecycle.detail.distributed')}</dt>
<dd><NodeDistributionChip nodes={model.loaded_on} context="models" compactThreshold={20} /></dd>
</>)}
{model.source && (<>
<dt>{t('lifecycle.detail.source')}</dt>
<dd className="cell-muted">{model.source}</dd>
</>)}
{files.length > 0 && (<>
<dt>{t('lifecycle.detail.files')}</dt>
<dd className="cell-muted">{t('lifecycle.detail.fileCount', { count: files.length })}</dd>
</>)}
</dl>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import { Navigate, useLocation } from 'react-router-dom'
export default function ManageRedirect() {
const { search } = useLocation()
const legacy = new URLSearchParams(search)
const backends = legacy.get('tab') === 'backends'
const next = new URLSearchParams({ view: 'installed' })
const selection = legacy.get('sel')
const query = legacy.get(backends ? 'bq' : 'mq')
const state = legacy.get(backends ? 'bf' : 'mf')
if (selection) next.set(backends ? 'backend' : 'model', selection)
if (query) next.set('q', query)
if (state) next.set('state', state)
if (backends && legacy.get('bv') === '1') next.set('show_all', '1')
if (backends && legacy.get('bd') === '1') next.set('development', '1')
const path = backends ? '/app/backends' : '/app/models'
return <Navigate to={`${path}?${next.toString()}`} replace />
}
+4 -4
View File
@@ -332,8 +332,8 @@ export default function ModelEditor() {
try {
const parsed = YAML.parse(yamlText)
if (parsed?.name) navigate(`/app/model-editor/${encodeURIComponent(parsed.name)}`, { replace: true, state: backState })
else navigate(backState ? backState.from : '/app/manage')
} catch { navigate(backState ? backState.from : '/app/manage') }
else navigate(backState ? backState.from : '/app/models?view=installed')
} catch { navigate(backState ? backState.from : '/app/models?view=installed') }
} else {
const response = await fetch(apiUrl(`/models/edit/${encodeURIComponent(name)}`), {
method: 'POST',
@@ -426,7 +426,7 @@ export default function ModelEditor() {
const backPage = isCreateMode && selectedTemplate ? t('actions.templates')
: backState ? backState.fromLabel
: isCreateMode ? t('actions.models') : t('actions.system')
: t('actions.models')
return (
<FormContextProvider formData={values}>
@@ -445,7 +445,7 @@ export default function ModelEditor() {
<button className="btn btn-secondary" onClick={() => {
if (isCreateMode && selectedTemplate) { setSelectedTemplate(null); setValues({}); setActiveFieldPaths(new Set()) }
else if (backState) navigate(backState.from)
else navigate(isCreateMode ? '/app/models' : '/app/manage')
else navigate(isCreateMode ? '/app/models' : '/app/models?view=installed')
}}>
<i className="fas fa-arrow-left" /> {t('actions.backTo', {page: backPage})}
</button>
+171 -71
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useNavigate, useOutletContext, useLocation, useSearchParams } from 'react-router-dom'
import { Link, useNavigate, useOutletContext, useLocation, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import { modelsApi } from '../utils/api'
@@ -9,14 +9,12 @@ import { useOperations } from '../hooks/useOperations'
import { useResources } from '../hooks/useResources'
import SearchableSelect from '../components/SearchableSelect'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import GalleryLoader from '../components/GalleryLoader'
import Toggle from '../components/Toggle'
import RecommendedModels from '../components/RecommendedModels'
import SplitView from '../components/split/SplitView'
import EntityRail from '../components/split/EntityRail'
import DetailHeader from '../components/split/DetailHeader'
import StatGrid from '../components/split/StatGrid'
import InstalledModels, { ModelLifecycleDetailShell, modelUseCases } from './InstalledModels'
import { formatBytes } from '../utils/format'
import { ENTITY_GROUPS, groupForEntity } from '../utils/entityGroups'
import { renderMarkdown, stripMarkdown } from '../utils/markdown'
@@ -109,6 +107,35 @@ const FILTER_SECTIONS = [
keys: ['image', 'video', '3d'] },
]
function ModelsLifecycleNav({ activeView, searchParams, t }) {
const hrefFor = view => {
const next = new URLSearchParams(searchParams)
if (view === 'installed') next.set('view', 'installed')
else next.delete('view')
const query = next.toString()
return `/app/models${query ? `?${query}` : ''}`
}
return (
<nav className="tabs mb-md" aria-label={t('lifecycle.navLabel')}>
<Link
className={`tab ${activeView === 'explore' ? 'tab-active' : ''}`}
to={hrefFor('explore')}
aria-current={activeView === 'explore' ? 'page' : undefined}
>
<i className="fas fa-compass" aria-hidden="true" /> {t('lifecycle.views.explore')}
</Link>
<Link
className={`tab ${activeView === 'installed' ? 'tab-active' : ''}`}
to={hrefFor('installed')}
aria-current={activeView === 'installed' ? 'page' : undefined}
>
<i className="fas fa-hard-drive" aria-hidden="true" /> {t('lifecycle.views.installed')}
</Link>
</nav>
)
}
export default function Models() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
@@ -116,22 +143,28 @@ export default function Models() {
const { t } = useTranslation('models')
const { operations } = useOperations()
const { resources } = useResources()
const [searchParams, setSearchParams] = useSearchParams()
const activeView = searchParams.get('view') === 'installed' ? 'installed' : 'explore'
const installedState = ['running', 'idle', 'disabled', 'pinned', 'distributed'].includes(searchParams.get('state'))
? searchParams.get('state')
: 'all'
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [search, setSearch] = useState('')
const [search, setSearch] = useState(() => searchParams.get('q') || '')
const [filters, setFilters] = useState([])
const [sort, setSort] = useState('')
const [order, setOrder] = useState('asc')
const [installing, setInstalling] = useState(new Map())
const [installedProfiles, setInstalledProfiles] = useState({})
const [expandedFiles, setExpandedFiles] = useState(false)
// Which model the pane is showing, or null for the discovery shelves. It
// lives in the URL so a model is linkable and so Back steps out of the detail
// rather than off the page, which is the one thing the expanded row could
// never do.
const [searchParams, setSearchParams] = useSearchParams()
const selectedName = searchParams.get('model')
const urlSearch = searchParams.get('q') || ''
const [stats, setStats] = useState({ total: 0, installed: 0, repositories: 0 })
// Distinguishes "nothing installed" from "not asked yet". The recommendations
// panel defaults off the installed count, so it must not read the initial 0.
@@ -147,7 +180,6 @@ export default function Models() {
// True once any listing has come back. Distinguishes a cold start, which has
// nothing to keep on screen, from a refetch, which does.
const loadedOnce = useRef(false)
const [confirmDialog, setConfirmDialog] = useState(null)
// Variant descriptions, keyed by model name. The listing only tells us
// whether an entry declares any; describing them costs the server a network
// probe per variant, so we ask for one entry at a time and keep the answer
@@ -253,6 +285,26 @@ export default function Models() {
if (!loading) fetchModels()
}, [operations.length])
// Gallery entries only say whether a model is installed. The capabilities
// endpoint is authoritative about what that installation can open, so the
// Explore detail uses it for a useful primary action without duplicating
// destructive lifecycle controls from Installed.
useEffect(() => {
if (activeView !== 'explore') return undefined
let cancelled = false
modelsApi.listCapabilities()
.then(data => {
if (cancelled) return
setInstalledProfiles(Object.fromEntries(
(data?.data || []).map(profile => [profile.id, profile])
))
})
// A background refresh should not remove an action that was already
// resolved successfully earlier in this page session.
.catch(() => {})
return () => { cancelled = true }
}, [activeView, operations.length])
const debouncedFetch = useDebouncedCallback((value) => {
setPage(1)
fetchModels({ search: value, page: 1 })
@@ -318,9 +370,28 @@ export default function Models() {
const handleSearch = (value) => {
setSearch(value)
setSearchParams(previous => {
const next = new URLSearchParams(previous)
if (value) next.set('q', value)
else next.delete('q')
return next
}, { replace: true })
debouncedFetch(value)
}
// Search is URL-owned. Popstate changes therefore update the controlled
// field and refetch the gallery instead of leaving the previous term on
// screen after Back or Forward.
useEffect(() => {
if (urlSearch === search) return
setSearch(urlSearch)
setPage(1)
debouncedFetch(urlSearch)
// debouncedFetch intentionally follows the URL value only. Depending on
// the callback itself would restart this effect whenever fetch state moves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlSearch])
const toggleFilter = (key) => {
if (key === '') { setFilters([]); setPage(1); return }
setFilters(prev =>
@@ -396,26 +467,6 @@ export default function Models() {
}
}
const handleDelete = (modelId) => {
setConfirmDialog({
title: t('deleteDialog.title'),
message: t('deleteDialog.message', { model: modelId }),
confirmLabel: t('deleteDialog.confirm', { model: modelId }),
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
try {
await modelsApi.delete(modelId)
addToast(t('deleteDialog.deletingToast', { model: modelId }), 'info')
fetchModels()
} catch (err) {
addToast(t('errors.deleteFailed', { message: err.message }), 'error')
}
},
})
return
}
// Clear local installing flags when operations finish (success or error)
useEffect(() => {
if (installing.size === 0) return
@@ -512,12 +563,61 @@ export default function Models() {
setExpandedFiles(false)
}, [setSearchParams])
const setInstalledQuery = useCallback(value => {
setSearchParams(previous => {
const next = new URLSearchParams(previous)
// `all` is a valid search term. Only an empty string clears q.
if (value) next.set('q', value)
else next.delete('q')
return next
}, { replace: true })
}, [setSearchParams])
const setInstalledState = useCallback(value => {
setSearchParams(previous => {
const next = new URLSearchParams(previous)
// State alone reserves `all` as its default sentinel. q and model may
// both name a literal installed model called "all".
if (value && value !== 'all') next.set('state', value)
else next.delete('state')
return next
})
}, [setSearchParams])
// The detail pane lists variants, so opening a model is the ask that pays for
// the describe call. loadVariants is idempotent per name.
useEffect(() => {
if (selectedModel?.has_variants) loadVariants(selectedName)
}, [selectedName, selectedModel, loadVariants])
if (activeView === 'installed') {
return (
<div className="page page--wide page--app">
<div className="view-bar">
<h1 className="view-bar__title">{t('lifecycle.title')}</h1>
<div className="view-bar__actions">
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, t('lifecycle.title')) })}>
<i className="fas fa-plus" /> {t('actions.addModel')}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> {t('actions.importModel')}
</button>
</div>
</div>
<ModelsLifecycleNav activeView={activeView} searchParams={searchParams} t={t} />
<InstalledModels
addToast={addToast}
query={urlSearch}
state={installedState}
selectedName={selectedName}
onQueryChange={setInstalledQuery}
onStateChange={setInstalledState}
onSelect={selectModel}
/>
</div>
)
}
return (
<div className="page page--wide page--app">
{/* Title only. The two counts used to live here as well, which meant the
@@ -526,7 +626,7 @@ export default function Models() {
rail and the pane are describing what you are looking at; the header
was just repeating them from a distance. */}
<div className="view-bar">
<h1 className="view-bar__title">{t('title')}</h1>
<h1 className="view-bar__title">{t('lifecycle.title')}</h1>
<span className="view-bar__count">{t('rail.showingCount', { shown: visibleModels.length, total: stats.total })}</span>
<div className="view-bar__actions">
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, t('models')) })}>
@@ -538,6 +638,8 @@ export default function Models() {
</div>
</div>
<ModelsLifecycleNav activeView={activeView} searchParams={searchParams} t={t} />
{/* Filters, in three deliberate bands.
1. Query scope: free-text search plus the backend select. The backend
select leads the taxonomy row rather than trailing it because
@@ -766,7 +868,14 @@ export default function Models() {
installing={isInstalling(selectedName)}
progress={getOperationProgress(selectedName)}
onInstall={handleInstall}
onDelete={handleDelete}
installedProfile={installedProfiles[selectedName]}
onOpen={route => navigate(route)}
onManage={name => setSearchParams(previous => {
const next = new URLSearchParams(previous)
next.set('view', 'installed')
next.set('model', name)
return next
})}
onBack={() => selectModel(null)}
expandedFiles={expandedFiles}
setExpandedFiles={setExpandedFiles}
@@ -832,15 +941,6 @@ export default function Models() {
/>
)}
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}
message={confirmDialog?.message}
confirmLabel={confirmDialog?.confirmLabel}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
</div>
)
}
@@ -1162,7 +1262,7 @@ function ModelDetail({ model, fit, sizeDisplay, vramDisplay, expandedFiles, setE
// railItemFor maps a gallery entry onto the shape EntityRail speaks. Keeping
// the vocabulary translation here, rather than teaching the rail about models,
// is what lets Backends and Host reuse the same component without three
// is what lets Models and Backends reuse the same component without two
// slightly different rails growing out of it.
//
// The rail line gets exactly one fact beyond the name, and it is spent on
@@ -1314,7 +1414,7 @@ function VramByContext({ estimate, contextSize, onPickContext, totalGpuMemory, t
// entry's fields and is shared with the per-variant panel.
function DiscoverDetail({
model, estimate, contextSize, onPickContext, totalGpuMemory, fitsGpu,
installing, progress, onInstall, onDelete, onBack,
installing, progress, onInstall, installedProfile, onOpen, onManage, onBack,
expandedFiles, setExpandedFiles, variantData, variantDetails, onLoadVariantDetail, t,
}) {
const name = model.name || model.id
@@ -1323,19 +1423,19 @@ function DiscoverDetail({
const fit = fitsGpu(vramBytes)
const contextLabel = CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)]
const headroom = totalGpuMemory > 0 && vramBytes ? totalGpuMemory * 0.95 - vramBytes : null
const openUseCase = modelUseCases(installedProfile).find(useCase => useCase.route)
return (
<div className="detail-pane">
<DetailHeader
testId="discover"
icon={groupForEntity(model).icon}
name={name}
lede={model.description ? stripMarkdown(model.description).slice(0, 220) : null}
ledeTitle={model.description ? stripMarkdown(model.description) : null}
onBack={onBack}
backLabel={t('detail.backToAll')}
warning={model.trustRemoteCode ? t('detail.requiresTrustRemoteCode') : null}
actions={
<ModelLifecycleDetailShell
testId="discover"
icon={groupForEntity(model).icon}
name={name}
lede={model.description ? stripMarkdown(model.description).slice(0, 220) : null}
ledeTitle={model.description ? stripMarkdown(model.description) : null}
onBack={onBack}
backLabel={t('detail.backToAll')}
warning={model.trustRemoteCode ? t('detail.requiresTrustRemoteCode') : null}
actions={
installing ? (
<div className="inline-install">
<div className="inline-install__row">
@@ -1352,11 +1452,14 @@ function DiscoverDetail({
</div>
) : model.installed ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => onInstall(name)}>
<i className="fas fa-rotate" /> {t('actions.reinstall')}
</button>
<button className="btn btn-danger btn-sm" onClick={() => onDelete(name)}>
<i className="fas fa-trash" /> {t('actions.delete')}
{openUseCase && (
<button className="btn btn-primary btn-sm" onClick={() => onOpen(openUseCase.route(name))}>
<i className="fas fa-arrow-up-right-from-square" aria-hidden="true" />
{t('lifecycle.actions.open', { useCase: t(`lifecycle.open.${openUseCase.labelKey}`) })}
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => onManage(name)}>
<i className="fas fa-sliders" aria-hidden="true" /> {t('lifecycle.actions.manageInstallation')}
</button>
</>
) : (
@@ -1364,20 +1467,17 @@ function DiscoverDetail({
<i className="fas fa-download" /> {t('actions.install')}
</button>
)
}
/>
<StatGrid
stats={[
{ label: t('detail.size'), value: sizeDisplay && sizeDisplay !== '0 B' ? sizeDisplay : '—' },
{ label: t('detail.vramAt', { context: contextLabel }), value: vramBytes ? formatBytes(vramBytes) : '' },
{
label: t('detail.headroom'),
value: headroom === null ? '—' : (headroom < 0 ? '' : '') + formatBytes(Math.abs(headroom)),
tone: headroom === null ? undefined : headroom < 0 ? 'bad' : 'ok',
},
]}
/>
}
stats={[
{ label: t('detail.size'), value: sizeDisplay && sizeDisplay !== '0 B' ? sizeDisplay : '—' },
{ label: t('detail.vramAt', { context: contextLabel }), value: vramBytes ? formatBytes(vramBytes) : '—' },
{
label: t('detail.headroom'),
value: headroom === null ? '—' : (headroom < 0 ? '' : '') + formatBytes(Math.abs(headroom)),
tone: headroom === null ? undefined : headroom < 0 ? 'bad' : 'ok',
},
]}
>
<VramByContext
estimate={estimate}
@@ -1402,6 +1502,6 @@ function DiscoverDetail({
nested
t={t}
/>
</div>
</ModelLifecycleDetailShell>
)
}
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import PageHeader from '../components/PageHeader'
import { ResourceMonitorView } from '../components/ResourceMonitor'
import Sparkline from '../components/Sparkline'
import { useOperateSummary } from '../contexts/OperateSummaryContext'
import { staggerStyle } from '../hooks/useStagger'
@@ -77,6 +78,29 @@ export default function OperateOverview() {
<p className="operate-clear operate-headline__note">{t('operate.overview.headline.quiet')}</p>
)}
<section>
<ResourceMonitorView
resources={summary?.resources}
loading={summary?.resourcesLoading}
unavailable={summary?.resourcesUnavailable}
title={t('operate.overview.capacity.title')}
loadingText={t('operate.overview.capacity.loading')}
unavailableText={t('operate.overview.capacity.unavailable')}
emptyText={t('operate.overview.capacity.empty')}
copy={{
gpuCount: count => t('operate.overview.capacity.gpus', { count }),
reclaimer: t('operate.overview.capacity.reclaimer'),
used: t('operate.overview.capacity.used'),
total: t('operate.overview.capacity.total'),
systemRam: t('operate.overview.capacity.systemRam'),
memory: t('operate.overview.capacity.memory'),
totalVram: t('operate.overview.capacity.totalVram'),
storage: t('operate.overview.capacity.storage'),
}}
testId="operate-capacity"
/>
</section>
<section>
<div className="lane-head"><h2>{t('operate.overview.attention.heading')}</h2></div>
{attention.length === 0 ? (
@@ -142,7 +166,7 @@ export default function OperateOverview() {
/>
<OperateSection
index={3}
to="/app/manage"
to="/app/settings"
label={t('operate.overview.sections.administration')}
summary={t('operate.overview.sections.administrationSummary', {
memory: summary?.signals?.host || '—',
+3 -3
View File
@@ -35,7 +35,7 @@ export function preloadRoute(path) {
const Home = page('', () => import('./pages/Home'))
const Chat = page('chat', () => import('./pages/Chat'))
const Models = page('models', () => import('./pages/Models'))
const Manage = page('manage', () => import('./pages/Manage'))
const ManageRedirect = page('manage', () => import('./pages/ManageRedirect'))
const ImageGen = page('image', () => import('./pages/ImageGen'))
const VideoGen = page('video', () => import('./pages/VideoGen'))
const ThreeDGen = page('3d', () => import('./pages/ThreeDGen'))
@@ -179,12 +179,12 @@ const appChildren = [
{ path: 'usage', element: <Usage /> },
{ path: 'users', element: <RequireAuthEnabled><Admin><Users /></Admin></RequireAuthEnabled> },
{ path: 'middleware', element: <Admin><Middleware /></Admin> },
{ path: 'manage', element: <Admin><Manage /></Admin> },
],
},
// Model gallery (Discover) — top-level destination, full-width.
// Canonical resource pages and legacy management compatibility.
{ path: 'models', element: <Admin><Models /></Admin> },
{ path: 'manage', element: <Admin><ManageRedirect /></Admin> },
{ path: 'voice-library/new', element: <Admin><VoiceProfileCreate /></Admin> },
{ path: 'model-editor', element: <Admin><ModelEditor /></Admin> },
{ path: 'model-editor/:name', element: <Admin><ModelEditor /></Admin> },
+1 -1
View File
@@ -1,4 +1,4 @@
// Grouping for the rails on Discover and Backends.
// Grouping for the rails on Models Explore and Backends Catalog.
//
// The gallery does not tag entries with the use-case keys the filter chips
// send. Those keys (`chat`, `tts`, `transcript`, …) are a server-side
+1 -1
View File
@@ -6,7 +6,7 @@ const CREATE_PATHS = ['/app/chat', '/app/studio', '/app/talk']
// The section/console an app page belongs to, returned as a `nav` i18n key for
// use as the PageHeader eyebrow. Console pages map to their console title
// (Build / Operate); the inline Create group maps to sections.create; any other
// top-level page (Home, Discover, Account, ...) has no eyebrow.
// top-level page (Home, Models, Account, ...) has no eyebrow.
export function sectionKeyForPath(pathname) {
for (const c of consoles) {
if (consolePaths(c).some(p => pathname === p || pathname.startsWith(p + '/'))) {
+1
View File
@@ -42,6 +42,7 @@ export default defineConfig({
// core/http/app.go), so both proxied and root deployments load correctly.
base: './',
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': backendUrl,
+1 -1
View File
@@ -190,7 +190,7 @@ When authentication is enabled, the following endpoints require admin role:
When auth is enabled, the React UI sidebar dynamically shows/hides sections based on the user's role:
- **All users see**: Home, Chat, Images, Video, TTS, Sound, Talk, Usage, API docs link
- **Admins also see**: Discover, Agents section (Agents, Skills, Memory, MCP CI Jobs), System section (Backends, Traces, Swarm, System, Settings)
- **Admins also see**: Models, the Build console (Agents, Skills, Memory, Jobs, Training, Recognition), and the Operate console (Backends, Activity, Nodes, Usage, Traces, Users, Middleware, Settings)
Admin-only pages are also protected at the router level - navigating directly to an admin URL redirects non-admin users to the home page.
+16 -10
View File
@@ -16,26 +16,32 @@ For the complete list of backends, the model families they support, and their ac
## Managing Backends in the UI
The LocalAI web interface provides an intuitive way to manage your backends:
The **Operate → Backends** page is the canonical home for the complete backend
lifecycle:
1. Navigate to the "Backends" section in the navigation menu
2. Browse available backends from configured galleries
3. Use the search bar to find specific backends by name, description, or type
4. Filter backends by type using the quick filter buttons (LLM, Diffusion, TTS, Whisper)
5. Install or delete backends with a single click
6. Monitor installation progress in real-time
1. **Catalog** browses configured galleries, searches by name or description,
filters by capability, and installs a backend. Catalog is the default view.
2. **Installed** shows the runtimes present on the host or cluster. Search and
filter by user, system, update, or offline-node state, then select a backend
to inspect its version, source, node placement, and lifecycle actions.
3. Variant and development builds remain opt-in refinements. Target-node links
compose with the current view and selection instead of opening a separate
management page.
The current view, search, filter, selected backend, and target node are stored
in the URL. Browser Back and shared links therefore restore the same state.
Installs run in the background. The strip at the top of the app follows the
current one, and **Operate → Activity** lists everything in flight, what needs
attention, and what has finished, and is where a running install is cancelled
or a failed one retried. See [Activity]({{% relref "operations/activity" %}}).
Each backend card displays:
Each selected backend displays:
- Backend name and description
- Type of models it supports
- Installation status
- Action buttons (Install/Delete)
- Additional information via the info button
- Install, reinstall, upgrade, or delete actions as appropriate
- Version, source, digest, placement, and catalog information
## Backend Galleries
+13 -1
View File
@@ -29,7 +29,19 @@ GPT and text generation models might have a license which is not permissive for
## How it works
Navigate the WebUI interface in the "Models" section from the navbar at the top. Here you can find a list of models that can be installed, and you can install them by clicking the "Install" button.
Open **Models** in the WebUI. It is the canonical page for a model's complete
lifecycle and has two views:
- **Explore** browses configured galleries, compares hardware fit and variants,
and installs models. This is the default view.
- **Installed** lists local model configurations and their running, idle,
disabled, pinned, and distributed state. Select a model to load or stop it,
edit its configuration, open a supported use case, inspect backend logs, or
remove it.
Both views use the same model selection and store the view, search, filter, and
selection in the URL. Installing from Explore does not move you away from the
catalog; the entry updates in place when the operation finishes.
## VRAM and download size estimates
+9 -2
View File
@@ -22,8 +22,8 @@ The Model Gallery is the simplest way to install models. It provides pre-configu
### Via WebUI
1. Open the LocalAI WebUI at `http://localhost:8080`
2. Navigate to the "Models" tab
3. Browse available models
2. Navigate to **Models → Explore**
3. Browse or search the available models
4. Click "Install" on any model you want
5. Wait for installation to complete. Progress appears in the strip at the top
of the app, and **Operate → Activity** shows every install in flight, plus
@@ -31,6 +31,13 @@ The Model Gallery is the simplest way to install models. It provides pre-configu
For more details, refer to the [Gallery Documentation]({{% relref "features/model-gallery" %}}).
The same Models page owns the complete lifecycle. Switch to **Installed** to
search local configurations, filter them by running, idle, disabled, pinned,
or distributed state, and open a model's runtime controls. Load, stop, edit,
pin, disable, inspect backend logs, and remove actions stay with the selected
model. The current view, search, filter, and selection are stored in the URL so
links and browser history preserve your place.
### Via CLI
```bash
+2 -2
View File
@@ -52,7 +52,7 @@ For NVIDIA GPUs, add `--gpus all`. For AMD/Intel/Vulkan, add the appropriate `--
Open **http://localhost:8080** in your browser. The web interface lets you:
- **Chat** with any installed model
- **Install models** from the built-in gallery (Discover page)
- **Explore, install, and manage models** from the Models page
- **Generate images**, audio, and more
- **Create and manage AI agents** with MCP tool support
- **Monitor system resources** and loaded models
@@ -60,7 +60,7 @@ Open **http://localhost:8080** in your browser. The web interface lets you:
To get your first chat working:
1. Open the **Models** page and search for `qwen3-4b`. Click **Install** on the `qwen3-4b` entry and wait for the download to finish. (`qwen3-4b` is a small, CPU-friendly Qwen3 model that also supports tool calling, so you can reuse it later in the [Build your first agent]({{% relref "getting-started/first-agent" %}}) walkthrough.)
1. Open **Models → Explore** and search for `qwen3-4b`. Click **Install** on the `qwen3-4b` entry and wait for the download to finish. (`qwen3-4b` is a small, CPU-friendly Qwen3 model that also supports tool calling, so you can reuse it later in the [Build your first agent]({{% relref "getting-started/first-agent" %}}) walkthrough.)
2. Open the **Chat** page, select `qwen3-4b` from the model dropdown, type a message, and send it. You should get a reply within a few seconds.
To correct an earlier prompt or response without running the model again, hover
+19 -2
View File
@@ -48,12 +48,29 @@ The endpoint exists so a dashboard wanting three numbers does not fetch the
whole trace list to count it. An installation that has served nothing yet says
so rather than showing three zeroes dressed as telemetry.
## Host capacity
The overview also shows the host's current RAM or GPU capacity, utilization,
and model storage. Loading, unavailable, and empty states are explicit. This
uses the same 15-second Operate summary poll as the rail and attention data, so
opening the overview does not start a second resource poller.
Models and backends no longer live under a nested Host page. Use **Models →
Installed** for model runtime and configuration actions, and **Operate →
Backends → Installed** for installed backend actions. The overview links into
the canonical Operate sections rather than duplicating those inventories.
Old `/app/manage` bookmarks remain supported. They redirect with replace
semantics to the matching Installed Models or Installed Backends view while
preserving legacy search, filter, selection, variant, and development flags.
## The rail
The Operate rail groups its thirteen destinations under four headings —
The Operate rail groups its destinations under four headings —
Runtime, Cluster, Observability and Administration — and shows a live value
beside several of them: pending backend updates, running operations, healthy
node count, host memory, request volume and error count.
node count, request volume and error count. Host capacity lives on the overview
instead of appearing as a separate destination.
Those values are **orientation, not an alarm**. The rail only exists on Operate
routes and can be collapsed, so anything urgent also appears in Needs attention