From c0b7e64973a8048da9383fd2eeb387ceef09c417 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Mon, 21 Sep 2026 19:12:35 +0200 Subject: [PATCH] feat(ui): show running models and host gauges on single-node installs (#12189) On a single-node install nothing in Operate listed the models loaded on this machine or let an admin stop one. The System page that did was retired in #11548, and its replacements (the Nodes workbench) only work in distributed mode. The Nodes page also mis-detected single-node mode: the cluster routes are not registered there, so /api/nodes answers 404, but only 503 was treated as "distributed off", which sent every single-node install to the empty worker-registration card. The rail hid the entry anyway. Nodes route on a single node becomes "This machine": - the Nodes page's VRAM / RAM / CPU / models-disk gauges, fed from this host by mapping /api/resources onto the worker heartbeat fields - a memory bar splitting host RAM by running model - a running-models table (backend, RSS, CPU share, uptime, PID) with search, sorting, logs and a confirmed Stop - the distributed setup behind an "Add machines" button The Operate overview gains a "Running now" preview (heaviest five, with Stop) on single node and a pointer to Nodes > Running models on a cluster. The rail shows "This machine" in Runtime with a running count. Backend, additive only: - /system: each loaded model carries a `process` block (pid, rss_bytes, memory_percent, cpu_percent, started_at). A sampler keeps one gopsutil handle per PID so CPU is the share since the previous poll rather than the lifetime average; it is omitted on the first reading. - /api/resources: host `cpu` and models-path `disk`, the same readings workers send in their heartbeat. Also fixes the fleet tables widening the page on phones: the headers' absolutely positioned sr-only labels escaped the scroll wrapper. Assisted-by: Claude:claude-opus-5 [Playwright] Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/http/endpoints/localai/system.go | 29 ++- core/http/react-ui/e2e/local-machine.spec.js | 178 ++++++++++++++++++ core/http/react-ui/e2e/nodes-roster.spec.js | 20 +- .../react-ui/e2e/operate-overview.spec.js | 6 +- .../react-ui/public/locales/de/admin.json | 11 ++ core/http/react-ui/public/locales/de/nav.json | 3 +- .../react-ui/public/locales/en/admin.json | 11 ++ core/http/react-ui/public/locales/en/nav.json | 3 +- .../react-ui/public/locales/es/admin.json | 11 ++ core/http/react-ui/public/locales/es/nav.json | 3 +- .../react-ui/public/locales/id/admin.json | 11 ++ core/http/react-ui/public/locales/id/nav.json | 3 +- .../react-ui/public/locales/it/admin.json | 11 ++ core/http/react-ui/public/locales/it/nav.json | 3 +- .../react-ui/public/locales/ko/admin.json | 11 ++ core/http/react-ui/public/locales/ko/nav.json | 3 +- .../react-ui/public/locales/pt-BR/admin.json | 11 ++ .../react-ui/public/locales/pt-BR/nav.json | 3 +- .../react-ui/public/locales/zh-CN/admin.json | 11 ++ .../react-ui/public/locales/zh-CN/nav.json | 3 +- core/http/react-ui/src/App.css | 28 ++- .../src/components/console/consoleConfig.js | 7 + .../src/components/nodes/ClusterOverview.jsx | 10 +- .../src/components/nodes/HostOverview.jsx | 65 +++++++ .../src/components/nodes/LocalMachineView.jsx | 36 ++++ .../src/components/nodes/LocalModelTable.jsx | 85 +++++++++ .../components/nodes/LocalRunningModels.jsx | 93 +++++++++ .../src/components/nodes/ModelFleetTable.jsx | 2 +- .../src/contexts/OperateSummaryContext.jsx | 21 ++- .../react-ui/src/hooks/useLocalMachine.js | 43 +++++ core/http/react-ui/src/pages/Nodes.jsx | 26 +-- .../react-ui/src/pages/OperateOverview.jsx | 23 ++- core/http/react-ui/src/utils/localHost.js | 99 ++++++++++ .../http/react-ui/src/utils/localHost.test.js | 75 ++++++++ core/http/routes/localai.go | 2 +- core/http/routes/ui_api.go | 16 ++ core/schema/localai.go | 17 ++ core/services/monitoring/process_sampler.go | 77 ++++++++ .../monitoring/process_sampler_test.go | 79 ++++++++ docs/content/operations/overview.md | 33 ++++ docs/content/reference/system-info.md | 24 ++- 41 files changed, 1165 insertions(+), 41 deletions(-) create mode 100644 core/http/react-ui/e2e/local-machine.spec.js create mode 100644 core/http/react-ui/src/components/nodes/HostOverview.jsx create mode 100644 core/http/react-ui/src/components/nodes/LocalMachineView.jsx create mode 100644 core/http/react-ui/src/components/nodes/LocalModelTable.jsx create mode 100644 core/http/react-ui/src/components/nodes/LocalRunningModels.jsx create mode 100644 core/http/react-ui/src/hooks/useLocalMachine.js create mode 100644 core/http/react-ui/src/utils/localHost.js create mode 100644 core/http/react-ui/src/utils/localHost.test.js create mode 100644 core/services/monitoring/process_sampler.go create mode 100644 core/services/monitoring/process_sampler_test.go diff --git a/core/http/endpoints/localai/system.go b/core/http/endpoints/localai/system.go index 6a0b13965..996c9a781 100644 --- a/core/http/endpoints/localai/system.go +++ b/core/http/endpoints/localai/system.go @@ -1,9 +1,12 @@ package localai import ( + "strconv" + "github.com/labstack/echo/v4" "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/monitoring" "github.com/mudler/LocalAI/pkg/model" ) @@ -12,7 +15,7 @@ import ( // @Tags monitoring // @Success 200 {object} schema.SystemInformationResponse "Response" // @Router /system [get] -func SystemInformations(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc { +func SystemInformations(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, sampler *monitoring.LocalProcessSampler) echo.HandlerFunc { return func(c echo.Context) error { availableBackends := []string{} loadedModels := ml.ListLoadedModels() @@ -24,6 +27,7 @@ func SystemInformations(cl *config.ModelConfigLoader, ml *model.ModelLoader, app } sysmodels := []schema.SysInfoModel{} + live := map[int32]struct{}{} for _, m := range loadedModels { entry := schema.SysInfoModel{ID: m.ID} // The loader tracks only the ID. Which engine is serving a model is @@ -32,8 +36,17 @@ func SystemInformations(cl *config.ModelConfigLoader, ml *model.ModelLoader, app if cfg, ok := cl.GetModelConfig(m.ID); ok { entry.Backend = cfg.Backend } + if pid, ok := localPID(m); ok && sampler != nil { + live[pid] = struct{}{} + if proc, err := sampler.Sample(pid); err == nil { + entry.Process = proc + } + } sysmodels = append(sysmodels, entry) } + if sampler != nil { + sampler.Retain(live) + } return c.JSON(200, schema.SystemInformationResponse{ Backends: availableBackends, @@ -42,3 +55,17 @@ func SystemInformations(cl *config.ModelConfigLoader, ml *model.ModelLoader, app ) } } + +// localPID is the PID of the backend process this host started for m. A model +// served by a remote worker, or through an external gRPC address, has none. +func localPID(m *model.Model) (int32, bool) { + p := m.Process() + if p == nil { + return 0, false + } + pid, err := strconv.ParseInt(p.CurrentPID(), 10, 32) + if err != nil || pid <= 0 { + return 0, false + } + return int32(pid), true +} diff --git a/core/http/react-ui/e2e/local-machine.spec.js b/core/http/react-ui/e2e/local-machine.spec.js new file mode 100644 index 000000000..da764289d --- /dev/null +++ b/core/http/react-ui/e2e/local-machine.spec.js @@ -0,0 +1,178 @@ +import { test, expect } from './coverage-fixtures.js' + +// "This machine": the Nodes route on a single-node install, and the Operate +// overview's "Running now" preview. Both read GET /system (loaded models and +// their backend process) and GET /api/resources (host capacity). + +const GB = 1024 ** 3 + +function model(id, backend, rssGB, cpu) { + return { + id, + backend, + process: { + pid: 4000 + rssGB, + rss_bytes: rssGB * GB, + memory_percent: rssGB / 64 * 100, + ...(cpu == null ? {} : { cpu_percent: cpu }), + started_at: new Date(Date.now() - 2 * 3600_000).toISOString(), + }, + } +} + +const RESOURCES = { + type: 'gpu', + available: true, + gpus: [{ total_vram: 24 * GB, used_vram: 18 * GB, free_vram: 6 * GB }], + ram: { total: 64 * GB, used: 32 * GB, free: 8 * GB, available: 32 * GB }, + cpu: { logical_cores: 16, usage_percent: 50, load_1: 7.5 }, + disk: { total: 1000 * GB, used: 400 * GB, available: 600 * GB }, + aggregate: {}, +} + +// The cluster API is not mounted on a single node, so it answers 404. +async function mockSingleNode(page, loaded) { + const state = { loaded: [...loaded], shutdowns: [] } + await page.route('**/api/features', route => route.fulfill({ json: { distributed: false, agents: true, mcp: true } })) + await page.route('**/api/nodes', route => route.fulfill({ status: 404, json: { message: 'Not Found' } })) + await page.route('**/api/resources', route => route.fulfill({ json: RESOURCES })) + await page.route('**/system', route => route.fulfill({ json: { backends: [], loaded_models: state.loaded } })) + await page.route('**/backend/shutdown', async route => { + const body = route.request().postDataJSON() + state.shutdowns.push(body.model) + state.loaded = state.loaded.filter(m => m.id !== body.model) + await route.fulfill({ json: {} }) + }) + return state +} + +test.describe('This machine (single node)', () => { + test('draws the host gauges and one row per loaded model', async ({ page }) => { + await mockSingleNode(page, [model('qwen3-8b', 'llama-cpp', 12, 35.5), model('whisper-1', 'whisper', 2)]) + await page.goto('/app/nodes') + + const overview = page.getByTestId('host-overview') + await expect(overview).toBeVisible({ timeout: 15_000 }) + await expect(overview.getByLabel('VRAM capacity', { exact: true })).toContainText('75%') + await expect(overview.getByLabel('RAM capacity', { exact: true })).toContainText('50%') + await expect(overview.getByLabel('CPU capacity', { exact: true })).toContainText('8 busy / 16 cores') + await expect(overview.getByLabel('Models disk capacity', { exact: true })).toContainText('40%') + // A host is not a fleet: no "N nodes unavailable" coverage line. + await expect(overview.locator('.fleet-gauge__coverage')).toHaveCount(0) + await expect(overview.getByLabel('Running models summary')).toContainText('2 running') + await expect(overview.getByRole('img', { name: /qwen3-8b 12 GB, whisper-1 2 GB/ })).toBeVisible() + + const rows = page.getByTestId('local-model-row') + await expect(rows).toHaveCount(2) + const qwen = rows.filter({ hasText: 'qwen3-8b' }) + await expect(qwen).toContainText('llama-cpp') + await expect(qwen).toContainText('12 GB') + await expect(qwen).toContainText('35.5%') + await expect(qwen).toContainText('2h 0m') + // No CPU reading yet reads as unmeasured, not as idle. + await expect(rows.filter({ hasText: 'whisper-1' }).getByLabel('CPU not measured yet')).toBeVisible() + }) + + test('stops a model after confirmation and drops it from the list', async ({ page }) => { + const state = await mockSingleNode(page, [model('qwen3-8b', 'llama-cpp', 12, 10), model('whisper-1', 'whisper', 2, 1)]) + await page.goto('/app/nodes') + await expect(page.getByTestId('local-model-row')).toHaveCount(2, { timeout: 15_000 }) + + await page.getByRole('button', { name: 'Actions for qwen3-8b' }).click() + await page.getByRole('menuitem', { name: 'Stop model…' }).click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Stop qwen3-8b?') + await expect(dialog).toContainText('llama-cpp') + await dialog.getByRole('button', { name: 'Stop model' }).click() + + await expect(page.getByTestId('local-model-row')).toHaveCount(1) + await expect(page.getByTestId('local-model-row')).toContainText('whisper-1') + expect(state.shutdowns).toEqual(['qwen3-8b']) + }) + + test('cancelling the stop leaves the model running', async ({ page }) => { + const state = await mockSingleNode(page, [model('qwen3-8b', 'llama-cpp', 12, 10)]) + await page.goto('/app/nodes') + await page.getByRole('button', { name: 'Actions for qwen3-8b' }).click({ timeout: 15_000 }) + await page.getByRole('menuitem', { name: 'Stop model…' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByRole('alertdialog')).toHaveCount(0) + await expect(page.getByTestId('local-model-row')).toHaveCount(1) + expect(state.shutdowns).toEqual([]) + }) + + test('searches by model or backend and sorts by memory', async ({ page }) => { + await mockSingleNode(page, [model('alpha', 'llama-cpp', 2, 1), model('beta', 'whisper', 12, 1), model('gamma', 'llama-cpp', 6, 1)]) + await page.goto('/app/nodes') + const rows = page.getByTestId('local-model-row') + await expect(rows).toHaveCount(3, { timeout: 15_000 }) + + await page.getByRole('searchbox', { name: 'Search running models' }).fill('whisper') + await expect(rows).toHaveCount(1) + await expect(rows).toContainText('beta') + await page.getByRole('searchbox', { name: 'Search running models' }).fill('') + + await page.getByRole('button', { name: /Sort by memory/ }).click() + await page.getByRole('button', { name: /Sort by memory/ }).click() + await expect(rows.first()).toContainText('beta') + await expect(rows.last()).toContainText('alpha') + }) + + test('opens the model logs', async ({ page }) => { + await mockSingleNode(page, [model('qwen3-8b', 'llama-cpp', 12, 10)]) + await page.goto('/app/nodes') + await page.getByRole('button', { name: 'Actions for qwen3-8b' }).click({ timeout: 15_000 }) + await page.getByRole('menuitem', { name: 'View logs' }).click() + await expect(page).toHaveURL(/\/app\/backend-logs\/qwen3-8b$/) + }) + + test('says how models get here when nothing is loaded', async ({ page }) => { + await mockSingleNode(page, []) + await page.goto('/app/nodes') + await expect(page.getByTestId('local-running-empty')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('local-running-empty').getByRole('link', { name: 'Models' })).toHaveAttribute('href', '/app/models?view=installed') + await expect(page.getByLabel('VRAM capacity', { exact: true })).toContainText('75%') + }) + + test('says so when a CPU-only host has no GPU', async ({ page }) => { + await mockSingleNode(page, []) + await page.route('**/api/resources', route => route.fulfill({ json: { ...RESOURCES, type: 'ram', gpus: [] } })) + await page.goto('/app/nodes') + await expect(page.getByLabel('VRAM capacity', { exact: true })).toContainText('No GPU detected', { timeout: 15_000 }) + }) + + test('the Operate overview previews the heaviest five and links to the full view', async ({ page }) => { + await mockSingleNode(page, [1, 2, 3, 4, 5, 6].map(n => model(`m${n}`, 'llama-cpp', n, 1))) + await page.goto('/app/operate') + + const preview = page.getByTestId('local-running-models') + await expect(preview.getByTestId('local-model-row')).toHaveCount(5, { timeout: 15_000 }) + await expect(preview.getByTestId('local-model-row').first()).toContainText('m6') + await expect(preview).toContainText('1 more not shown') + // The preview is not a second search surface. + await expect(preview.getByRole('searchbox')).toHaveCount(0) + + const rail = page.locator('.console-rail a.nav-item[href="/app/nodes"]') + await expect(rail).toContainText('This machine') + await expect(rail.locator('.nav-signal')).toContainText('6') + + await preview.getByRole('link', { name: /Open this machine/ }).click() + await expect(page).toHaveURL(/\/app\/nodes$/) + await expect(page.getByTestId('local-machine')).toBeVisible() + }) +}) + +test.describe('This machine (distributed)', () => { + test('the overview points at the cluster instead of polling the controller', async ({ page }) => { + const systemCalls = [] + await page.route('**/api/features', route => route.fulfill({ json: { distributed: true } })) + await page.route('**/api/nodes', route => route.fulfill({ json: [{ id: 'n1', name: 'atlas', status: 'healthy' }] })) + await page.route('**/system', route => { systemCalls.push(route.request().url()); return route.fulfill({ json: { loaded_models: [] } }) }) + await page.goto('/app/operate') + + await expect(page.getByRole('link', { name: /Running models/ })).toHaveAttribute('href', '/app/nodes', { timeout: 15_000 }) + await expect(page.getByTestId('local-running-models')).toHaveCount(0) + await expect(page.locator('.console-rail a.nav-item', { hasText: 'This machine' })).toHaveCount(0) + expect(systemCalls).toEqual([]) + }) +}) diff --git a/core/http/react-ui/e2e/nodes-roster.spec.js b/core/http/react-ui/e2e/nodes-roster.spec.js index 721f99766..c90587eb2 100644 --- a/core/http/react-ui/e2e/nodes-roster.spec.js +++ b/core/http/react-ui/e2e/nodes-roster.spec.js @@ -27,9 +27,19 @@ test.describe('Nodes fleet roster', () => { await expect(page.getByText('No workers registered yet')).toBeVisible({ timeout: 15_000 }) }) - test('preserves the distributed-disabled setup experience', async ({ page }) => { - await page.route('**/api/nodes', route => route.fulfill({ status: 503, body: 'Service Unavailable' })) - await page.goto('/app/nodes') - await expect(page.getByText('Distributed Mode Not Enabled')).toBeVisible({ timeout: 15_000 }) - }) + // A single-node server does not register the cluster routes at all, so the + // real answer is 404; 503 is what they say when mounted without a registry. + // Either way the page is about this host, with the distributed setup one + // click away rather than the whole page. + for (const status of [404, 503]) { + test(`shows this machine when the cluster API answers ${status}`, async ({ page }) => { + await page.route('**/api/nodes', route => route.fulfill({ status, body: 'unavailable' })) + await page.goto('/app/nodes') + await expect(page.getByTestId('local-machine')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByText('No workers registered yet')).toHaveCount(0) + await expect(page.getByTestId('scale-out')).toHaveCount(0) + await page.getByRole('button', { name: 'Add machines' }).click() + await expect(page.getByTestId('scale-out')).toContainText('Distributed mode is not enabled') + }) + } }) diff --git a/core/http/react-ui/e2e/operate-overview.spec.js b/core/http/react-ui/e2e/operate-overview.spec.js index 5fde6485a..15abd6249 100644 --- a/core/http/react-ui/e2e/operate-overview.spec.js +++ b/core/http/react-ui/e2e/operate-overview.spec.js @@ -96,7 +96,11 @@ test.describe('Operate overview', () => { const rail = page.locator('.console-rail') await expect(rail.locator('a.nav-item[href="/app/backends"]')).toBeVisible() // Gating is the thing most likely to break silently when items move group. - await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toHaveCount(0) + // The Nodes route stays reachable, but as "This machine" in Runtime: a + // single host is not a cluster. + await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toHaveCount(1) + await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toContainText('This machine') + await expect(rail.locator('a.nav-item', { hasText: /^Nodes/ })).toHaveCount(0) await expect(rail.locator('a.nav-item[href="/app/scheduling"]')).toHaveCount(0) }) diff --git a/core/http/react-ui/public/locales/de/admin.json b/core/http/react-ui/public/locales/de/admin.json index bc6718dbf..43efb72da 100644 --- a/core/http/react-ui/public/locales/de/admin.json +++ b/core/http/react-ui/public/locales/de/admin.json @@ -252,7 +252,18 @@ "p95": "p95 latency", "quiet": "No requests served in this window yet.", "host": "Host memory" + }, + "running": { + "heading": "Gerade aktiv", + "cluster": "Laufende Modelle", + "clusterSummary": "Geladene Replikate im Cluster, auf der Seite Knoten" } } + }, + "localMachine": { + "title": "Dieser Rechner", + "subtitle": "Auf dieser LocalAI-Instanz geladene Modelle und die Host-Kapazität, die sie nutzen.", + "scaleOut": "Rechner hinzufügen", + "hideScaleOut": "Einrichtung ausblenden" } } diff --git a/core/http/react-ui/public/locales/de/nav.json b/core/http/react-ui/public/locales/de/nav.json index 46b0fca72..e881b3cad 100644 --- a/core/http/react-ui/public/locales/de/nav.json +++ b/core/http/react-ui/public/locales/de/nav.json @@ -59,7 +59,8 @@ "api": "API", "middleware": "Middleware", "activity": "Aktivität", - "overview": "Übersicht" + "overview": "Übersicht", + "thisMachine": "Dieser Rechner" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/en/admin.json b/core/http/react-ui/public/locales/en/admin.json index 20c9d70ad..7105c97ba 100644 --- a/core/http/react-ui/public/locales/en/admin.json +++ b/core/http/react-ui/public/locales/en/admin.json @@ -275,7 +275,18 @@ "p95": "p95 latency", "quiet": "No requests served in this window yet.", "host": "Host memory" + }, + "running": { + "heading": "Running now", + "cluster": "Running models", + "clusterSummary": "Loaded replicas across the cluster, on the Nodes page" } } + }, + "localMachine": { + "title": "This machine", + "subtitle": "Models loaded on this LocalAI instance and the host capacity they draw on.", + "scaleOut": "Add machines", + "hideScaleOut": "Hide setup" } } diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index 9ba499b2d..ac55dd533 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -60,7 +60,8 @@ "settings": "Settings", "api": "API", "activity": "Activity", - "overview": "Overview" + "overview": "Overview", + "thisMachine": "This machine" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/es/admin.json b/core/http/react-ui/public/locales/es/admin.json index bd6e01895..26345981f 100644 --- a/core/http/react-ui/public/locales/es/admin.json +++ b/core/http/react-ui/public/locales/es/admin.json @@ -252,7 +252,18 @@ "p95": "p95 latency", "quiet": "No requests served in this window yet.", "host": "Host memory" + }, + "running": { + "heading": "En ejecución", + "cluster": "Modelos en ejecución", + "clusterSummary": "Réplicas cargadas en el clúster, en la página Nodos" } } + }, + "localMachine": { + "title": "Esta máquina", + "subtitle": "Modelos cargados en esta instancia de LocalAI y la capacidad del host que utilizan.", + "scaleOut": "Añadir máquinas", + "hideScaleOut": "Ocultar configuración" } } diff --git a/core/http/react-ui/public/locales/es/nav.json b/core/http/react-ui/public/locales/es/nav.json index 70fba7bd1..8976ff270 100644 --- a/core/http/react-ui/public/locales/es/nav.json +++ b/core/http/react-ui/public/locales/es/nav.json @@ -59,7 +59,8 @@ "api": "API", "middleware": "Middleware", "activity": "Actividad", - "overview": "Resumen" + "overview": "Resumen", + "thisMachine": "Esta máquina" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/id/admin.json b/core/http/react-ui/public/locales/id/admin.json index 52afafb08..951c7faeb 100644 --- a/core/http/react-ui/public/locales/id/admin.json +++ b/core/http/react-ui/public/locales/id/admin.json @@ -275,7 +275,18 @@ "p95": "p95 latensi", "quiet": "Belum ada permintaan yang dilayani dalam jendela ini.", "host": "Memori host" + }, + "running": { + "heading": "Berjalan sekarang", + "cluster": "Model berjalan", + "clusterSummary": "Replika yang dimuat di seluruh klaster, di halaman Node" } } + }, + "localMachine": { + "title": "Mesin ini", + "subtitle": "Model yang dimuat di instans LocalAI ini dan kapasitas host yang digunakannya.", + "scaleOut": "Tambah mesin", + "hideScaleOut": "Sembunyikan pengaturan" } } diff --git a/core/http/react-ui/public/locales/id/nav.json b/core/http/react-ui/public/locales/id/nav.json index d0a85551c..ab8f8735d 100644 --- a/core/http/react-ui/public/locales/id/nav.json +++ b/core/http/react-ui/public/locales/id/nav.json @@ -60,7 +60,8 @@ "settings": "Pengaturan", "api": "API", "activity": "Aktivitas", - "overview": "Ikhtisar" + "overview": "Ikhtisar", + "thisMachine": "Mesin ini" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/it/admin.json b/core/http/react-ui/public/locales/it/admin.json index 48155bd09..7aaffb91f 100644 --- a/core/http/react-ui/public/locales/it/admin.json +++ b/core/http/react-ui/public/locales/it/admin.json @@ -252,7 +252,18 @@ "p95": "p95 latency", "quiet": "No requests served in this window yet.", "host": "Host memory" + }, + "running": { + "heading": "In esecuzione", + "cluster": "Modelli in esecuzione", + "clusterSummary": "Repliche caricate nel cluster, nella pagina Nodi" } } + }, + "localMachine": { + "title": "Questa macchina", + "subtitle": "Modelli caricati su questa istanza di LocalAI e la capacità dell'host che utilizzano.", + "scaleOut": "Aggiungi macchine", + "hideScaleOut": "Nascondi configurazione" } } diff --git a/core/http/react-ui/public/locales/it/nav.json b/core/http/react-ui/public/locales/it/nav.json index fd35543ae..74ff7c99d 100644 --- a/core/http/react-ui/public/locales/it/nav.json +++ b/core/http/react-ui/public/locales/it/nav.json @@ -59,7 +59,8 @@ "api": "API", "middleware": "Middleware", "activity": "Attività", - "overview": "Panoramica" + "overview": "Panoramica", + "thisMachine": "Questa macchina" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/ko/admin.json b/core/http/react-ui/public/locales/ko/admin.json index 8bf612247..c4dbb4321 100644 --- a/core/http/react-ui/public/locales/ko/admin.json +++ b/core/http/react-ui/public/locales/ko/admin.json @@ -275,7 +275,18 @@ "p95": "p95 latency", "quiet": "No requests served in this window yet.", "host": "Host memory" + }, + "running": { + "heading": "실행 중", + "cluster": "실행 중인 모델", + "clusterSummary": "클러스터 전체에 로드된 복제본, 노드 페이지에서 확인" } } + }, + "localMachine": { + "title": "이 머신", + "subtitle": "이 LocalAI 인스턴스에 로드된 모델과 모델이 사용하는 호스트 용량입니다.", + "scaleOut": "머신 추가", + "hideScaleOut": "설정 숨기기" } } diff --git a/core/http/react-ui/public/locales/ko/nav.json b/core/http/react-ui/public/locales/ko/nav.json index 3df98ab4d..16d40a299 100644 --- a/core/http/react-ui/public/locales/ko/nav.json +++ b/core/http/react-ui/public/locales/ko/nav.json @@ -59,7 +59,8 @@ "settings": "설정", "api": "API", "activity": "활동", - "overview": "개요" + "overview": "개요", + "thisMachine": "이 머신" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/pt-BR/admin.json b/core/http/react-ui/public/locales/pt-BR/admin.json index 3f1ff48c8..784c58193 100644 --- a/core/http/react-ui/public/locales/pt-BR/admin.json +++ b/core/http/react-ui/public/locales/pt-BR/admin.json @@ -275,7 +275,18 @@ "p95": "latência p95", "quiet": "Nenhuma requisição atendida nesta janela ainda.", "host": "Memória do host" + }, + "running": { + "heading": "Em execução", + "cluster": "Modelos em execução", + "clusterSummary": "Réplicas carregadas no cluster, na página Nós" } } + }, + "localMachine": { + "title": "Esta máquina", + "subtitle": "Modelos carregados nesta instância do LocalAI e a capacidade do host que utilizam.", + "scaleOut": "Adicionar máquinas", + "hideScaleOut": "Ocultar configuração" } } diff --git a/core/http/react-ui/public/locales/pt-BR/nav.json b/core/http/react-ui/public/locales/pt-BR/nav.json index 8f3f58d08..f0164bf0b 100644 --- a/core/http/react-ui/public/locales/pt-BR/nav.json +++ b/core/http/react-ui/public/locales/pt-BR/nav.json @@ -60,7 +60,8 @@ "settings": "Configurações", "api": "API", "activity": "Atividade", - "overview": "Visão geral" + "overview": "Visão geral", + "thisMachine": "Esta máquina" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/zh-CN/admin.json b/core/http/react-ui/public/locales/zh-CN/admin.json index d6289dc00..5b8ab1ef7 100644 --- a/core/http/react-ui/public/locales/zh-CN/admin.json +++ b/core/http/react-ui/public/locales/zh-CN/admin.json @@ -252,7 +252,18 @@ "p95": "p95 latency", "quiet": "No requests served in this window yet.", "host": "Host memory" + }, + "running": { + "heading": "正在运行", + "cluster": "运行中的模型", + "clusterSummary": "集群中已加载的副本,见节点页面" } } + }, + "localMachine": { + "title": "本机", + "subtitle": "此 LocalAI 实例上已加载的模型及其占用的主机容量。", + "scaleOut": "添加机器", + "hideScaleOut": "隐藏设置" } } diff --git a/core/http/react-ui/public/locales/zh-CN/nav.json b/core/http/react-ui/public/locales/zh-CN/nav.json index 0997d9180..6e88ead91 100644 --- a/core/http/react-ui/public/locales/zh-CN/nav.json +++ b/core/http/react-ui/public/locales/zh-CN/nav.json @@ -59,7 +59,8 @@ "api": "API", "middleware": "Middleware", "activity": "活动", - "overview": "概览" + "overview": "概览", + "thisMachine": "本机" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index f5ac4cd18..c6ea6e71d 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -9865,7 +9865,10 @@ button.collapsible-header:focus-visible { .fleet-bulkbar strong { font-size: var(--text-xs); margin-right: var(--spacing-xs); } .fleet-bulkbar__clear { background: none; border: 0; color: var(--color-primary); cursor: pointer; font: inherit; font-size: var(--text-xs); } .fleet-bulkbar__count { color: var(--color-text-muted); font-size: var(--text-xs); margin-left: auto; } -.fleet-table-wrap { overflow-x: auto; } +/* position: relative makes the wrap the containing block for the headers' + absolutely positioned .sr-only labels. Without it they escape the scroll + container and widen the whole page on a phone. */ +.fleet-table-wrap { overflow-x: auto; position: relative; } .fleet-table { border-collapse: collapse; font-size: var(--text-xs); min-width: 820px; width: 100%; } .fleet-table th, .fleet-table td { border-bottom: 1px solid var(--color-border-subtle); padding: 10px 12px; text-align: left; vertical-align: middle; } .fleet-table thead th { background: var(--color-bg-secondary); color: var(--color-text-muted); font-weight: 600; } @@ -9981,6 +9984,29 @@ button.collapsible-header:focus-visible { .model-inspector__node li span { color: var(--color-text-muted); } .model-inspector__node code { color: var(--color-text-secondary); font-size: .5625rem; overflow: hidden; text-align: right; text-overflow: ellipsis; white-space: nowrap; } +/* This machine: the single-node view of the Nodes page and the Operate + overview's "Running now" preview. Reuses the fleet widgets; only what one + host adds over a fleet is styled here. */ +.fleet-table__resource--cpu { --fleet-resource-color: var(--color-info); } +.host-memory__track { fill: var(--color-bg-tertiary); } +.host-memory__segment--0, .host-memory__dot--0 { fill: var(--color-primary); background: var(--color-primary); } +.host-memory__segment--1, .host-memory__dot--1 { fill: var(--color-success); background: var(--color-success); } +.host-memory__segment--2, .host-memory__dot--2 { fill: var(--color-info); background: var(--color-info); } +.host-memory__segment--3, .host-memory__dot--3 { fill: var(--color-accent); background: var(--color-accent); } +.host-memory__segment--4, .host-memory__dot--4 { fill: var(--color-secondary); background: var(--color-secondary); } +.host-memory__segment + .host-memory__segment { stroke: var(--color-bg-secondary); stroke-width: .4; } +.host-memory .fleet-health__headline { flex-wrap: wrap; row-gap: 2px; white-space: normal; } +.host-memory .fleet-health__legend > div > span { min-width: 0; } +.host-memory__name { overflow: hidden; text-overflow: ellipsis; } +.local-running { margin-bottom: var(--spacing-lg); } +.local-running .model-workbench__state { min-height: 180px; } +.local-running__empty a { color: var(--color-primary); } +.local-running__more { border-top: 1px solid var(--color-border-subtle); } +.local-model-table th:first-child, .local-model-table td:first-child { width: 30%; } +.local-model-table .fleet-table__row { cursor: default; } +.local-model-table .fleet-table__node { cursor: text; overflow-wrap: anywhere; } +.local-model-table .fleet-table__row.is-stopping { opacity: .55; } + @container fleet-page (max-width: 760px) { .fleet-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .fleet-overview__cell { min-height: 146px; } diff --git a/core/http/react-ui/src/components/console/consoleConfig.js b/core/http/react-ui/src/components/console/consoleConfig.js index 221e7307f..14bb43c83 100644 --- a/core/http/react-ui/src/components/console/consoleConfig.js +++ b/core/http/react-ui/src/components/console/consoleConfig.js @@ -67,6 +67,10 @@ export const operateConsole = { titleKey: 'operate.runtime', items: [ { path: '/app/operate', icon: 'fas fa-gauge-high', labelKey: 'items.overview', adminOnly: true, signal: 'attention' }, + // The Nodes route under the name it has on a single-node install, + // where it shows this host and what is loaded on it. With distributed + // mode on, the Cluster group's Nodes entry takes over instead. + { path: '/app/nodes', icon: 'fas fa-desktop', labelKey: 'items.thisMachine', adminOnly: true, unlessFeature: 'distributed', signal: 'running' }, { path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true, signal: 'backends' }, { path: '/app/voice-library', icon: 'fas fa-wave-square', labelKey: 'items.voiceLibrary', adminOnly: true }, { path: '/app/activity', icon: 'fas fa-download', labelKey: 'items.activity', adminOnly: true, badge: 'operations', signal: 'activity' }, @@ -109,6 +113,9 @@ export function isConsoleItemVisible(item, { isAdmin, authEnabled, hasFeature, f if (item.requiresAgentPool && features.agents === false) return false if (item.feature && features[item.feature] === false) return false if (item.feature && !hasFeature(item.feature)) return false + // Hidden until /api/features has answered: showing it and then swapping it + // for the cluster entry would move the rail under the user's pointer. + if (item.unlessFeature && features[item.unlessFeature] !== false) return false return true } diff --git a/core/http/react-ui/src/components/nodes/ClusterOverview.jsx b/core/http/react-ui/src/components/nodes/ClusterOverview.jsx index 4b57c4810..85d0d8d75 100644 --- a/core/http/react-ui/src/components/nodes/ClusterOverview.jsx +++ b/core/http/react-ui/src/components/nodes/ClusterOverview.jsx @@ -9,7 +9,9 @@ const ATTENTION = [ ['lowDisk', 'Low models disk'], ] -function CapacityGauge({ label, metric, cpu = false, tone }) { +// `single` drops the "N of M nodes reporting" coverage lines, which describe a +// fleet and read as a fault when the "fleet" is the one host being viewed. +export function CapacityGauge({ label, metric, cpu = false, tone, single = false, noDataText = 'No data' }) { const reporting = metric.reportingCount > 0 const percent = reporting ? Math.round(metric.usagePercent) : 0 const value = cpu @@ -17,7 +19,7 @@ function CapacityGauge({ label, metric, cpu = false, tone }) { : formatCapacity(metric.used, metric.total) const available = cpu ? `${Number(metric.idleCoreEquivalents.toFixed(1))} idle · load ${metric.load1.toFixed(2)}` - : reporting ? `${formatCapacity(metric.available, metric.total).split(' / ')[0]} available` : 'No data' + : reporting ? `${formatCapacity(metric.available, metric.total).split(' / ')[0]} available` : noDataText return (
@@ -31,8 +33,8 @@ function CapacityGauge({ label, metric, cpu = false, tone }) {
{reporting ? value : 'No data'}
{available}
- Capacity coverage: {metric.reportingCount} of {metric.reportingCount + metric.unknownCount} nodes reporting; {metric.unknownCount} unknown. - {metric.unknownCount > 0 &&
{metric.unknownCount} node{metric.unknownCount === 1 ? '' : 's'} unavailable
} + {!single && Capacity coverage: {metric.reportingCount} of {metric.reportingCount + metric.unknownCount} nodes reporting; {metric.unknownCount} unknown.} + {!single && metric.unknownCount > 0 &&
{metric.unknownCount} node{metric.unknownCount === 1 ? '' : 's'} unavailable
}
) } diff --git a/core/http/react-ui/src/components/nodes/HostOverview.jsx b/core/http/react-ui/src/components/nodes/HostOverview.jsx new file mode 100644 index 000000000..6f347a5e7 --- /dev/null +++ b/core/http/react-ui/src/components/nodes/HostOverview.jsx @@ -0,0 +1,65 @@ +import { CapacityGauge } from './ClusterOverview' +import { formatBytes } from './nodeStatus' + +// The single-node counterpart of ClusterOverview: the same four capacity +// gauges, fed from this host instead of summed across workers. The lead cell +// trades fleet health, which has nothing to say about one machine, for where +// the machine's memory is going: one bar segment per running model. + +const SEGMENTS = 5 +const MIN_SEGMENT = 0.8 +// The legend grid has three columns: three models, or two and a count. +const LEGEND_COLUMNS = 3 + +function MemoryShare({ models, ramTotal }) { + const measured = models + .filter(model => model.rss_bytes != null && model.rss_bytes > 0) + .sort((left, right) => right.rss_bytes - left.rss_bytes) + const modelBytes = measured.reduce((sum, model) => sum + model.rss_bytes, 0) + const scale = ramTotal > 0 ? ramTotal : modelBytes + const named = measured.length > LEGEND_COLUMNS ? LEGEND_COLUMNS - 1 : measured.length + let cursor = 0 + + return ( +
+ This machine +
+ {models.length} running + {modelBytes > 0 ? `${formatBytes(modelBytes)} resident${ramTotal > 0 ? ` of ${formatBytes(ramTotal)} RAM` : ''}` : 'no memory readings yet'} +
+ `${model.model_name} ${formatBytes(model.rss_bytes)}`).join(', ') || 'No models using memory'}> + + {scale > 0 && measured.map((model, index) => { + const start = cursor + // A floor so a model that is small next to the host still shows + // up as a sliver rather than vanishing; the label carries the size. + const width = Math.max(MIN_SEGMENT, model.rss_bytes / scale * 100) + cursor += width + return {`${model.model_name}: ${formatBytes(model.rss_bytes)}`} + })} + +
+ {measured.slice(0, named).map((model, index) => ( +
+ {model.model_name} + {formatBytes(model.rss_bytes)} +
+ ))} + {measured.length > named &&
Others{measured.length - named} more
} +
+
+ ) +} + +export default function HostOverview({ summary, models, ramTotal }) { + return ( +
+ + + + + +
+ ) +} diff --git a/core/http/react-ui/src/components/nodes/LocalMachineView.jsx b/core/http/react-ui/src/components/nodes/LocalMachineView.jsx new file mode 100644 index 000000000..9f95f5322 --- /dev/null +++ b/core/http/react-ui/src/components/nodes/LocalMachineView.jsx @@ -0,0 +1,36 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import PageHeader from '../PageHeader' +import HostOverview from './HostOverview' +import LocalRunningModels from './LocalRunningModels' +import { useLocalMachine } from '../../hooks/useLocalMachine' +import { hostAsNode } from '../../utils/localHost' +import { summarizeFleet } from '../../utils/nodeFleet' + +// What the Nodes page shows when distributed mode is off. It used to be only +// an "enable distributed mode" card, which left a single-node install with no +// page listing what was loaded and no way to stop it short of the API. The +// host is treated as a fleet of one so the gauges and the running-models table +// match what a cluster operator sees; the distributed setup moves behind a +// button, since most single-node installs are single-node on purpose. +export default function LocalMachineView({ addToast, scaleOut }) { + const { t } = useTranslation('admin') + const machine = useLocalMachine() + const [showScaleOut, setShowScaleOut] = useState(false) + const summary = useMemo(() => { + const node = hostAsNode(machine.resources) + return summarizeFleet(node ? [node] : []) + }, [machine.resources]) + + return ( +
+ setShowScaleOut(value => !value)}> +