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:
committed by
GitHub
parent
6fb9ab38aa
commit
0aaff91ebd
@@ -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.
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 @@
|
||||
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": "语音识别",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '—',
|
||||
|
||||
@@ -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
@@ -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
@@ -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 + '/'))) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user