diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 000000000..57b4e9b29 --- /dev/null +++ b/.impeccable.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index dd2c79125..71302268a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/core/http/react-ui/e2e/alias-template.spec.js b/core/http/react-ui/e2e/alias-template.spec.js index e9b13ba61..83d731b92 100644 --- a/core/http/react-ui/e2e/alias-template.spec.js +++ b/core/http/react-ui/e2e/alias-template.spec.js @@ -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: ` 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 }) }) }) diff --git a/core/http/react-ui/e2e/backends-lifecycle.spec.js b/core/http/react-ui/e2e/backends-lifecycle.spec.js new file mode 100644 index 000000000..28ebc88a7 --- /dev/null +++ b/core/http/react-ui/e2e/backends-lifecycle.spec.js @@ -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() + }) +}) diff --git a/core/http/react-ui/e2e/canonical-resource-navigation.spec.js b/core/http/react-ui/e2e/canonical-resource-navigation.spec.js new file mode 100644 index 000000000..7e2835589 --- /dev/null +++ b/core/http/react-ui/e2e/canonical-resource-navigation.spec.js @@ -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) + }) +}) diff --git a/core/http/react-ui/e2e/chrome-audit.spec.js b/core/http/react-ui/e2e/chrome-audit.spec.js index 728701003..43dd8525a 100644 --- a/core/http/react-ui/e2e/chrome-audit.spec.js +++ b/core/http/react-ui/e2e/chrome-audit.spec.js @@ -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', ] diff --git a/core/http/react-ui/e2e/console-narrow.spec.js b/core/http/react-ui/e2e/console-narrow.spec.js index 2497c657b..a7f7bfc73 100644 --- a/core/http/react-ui/e2e/console-narrow.spec.js +++ b/core/http/react-ui/e2e/console-narrow.spec.js @@ -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 - ) -} diff --git a/core/http/react-ui/src/components/RecommendedModels.jsx b/core/http/react-ui/src/components/RecommendedModels.jsx index 797b13e87..e42205945 100644 --- a/core/http/react-ui/src/components/RecommendedModels.jsx +++ b/core/http/react-ui/src/components/RecommendedModels.jsx @@ -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. // diff --git a/core/http/react-ui/src/components/ResourceMonitor.jsx b/core/http/react-ui/src/components/ResourceMonitor.jsx index ea5198e7f..81161113d 100644 --- a/core/http/react-ui/src/components/ResourceMonitor.jsx +++ b/core/http/react-ui/src/components/ResourceMonitor.jsx @@ -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
Loading resources...
+ return ( + + ) +} + +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
{loadingText}
+ } + + if (unavailable || !resources) { + return
{unavailableText}
} 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
{emptyText}
+ } return ( -
+

- System Resources +