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 <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
localai-org-maint-botandEttore Di Giacinto authored and GitHub committed 2026-09-21 19:12:35 +02:00
1 parent 728e08c6c2
commit c0b7e64973
41 files changed
+1165 -41

No files matched your search

+28 -1
View File
@@ -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
}
@@ -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([])
})
})
+15 -5
View File
@@ -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')
})
}
})
@@ -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)
})
@@ -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"
}
}
@@ -59,7 +59,8 @@
"api": "API",
"middleware": "Middleware",
"activity": "Aktivität",
"overview": "Übersicht"
"overview": "Übersicht",
"thisMachine": "Dieser Rechner"
},
"footer": {
"github": "GitHub",
@@ -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"
}
}
@@ -60,7 +60,8 @@
"settings": "Settings",
"api": "API",
"activity": "Activity",
"overview": "Overview"
"overview": "Overview",
"thisMachine": "This machine"
},
"footer": {
"github": "GitHub",
@@ -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"
}
}
@@ -59,7 +59,8 @@
"api": "API",
"middleware": "Middleware",
"activity": "Actividad",
"overview": "Resumen"
"overview": "Resumen",
"thisMachine": "Esta máquina"
},
"footer": {
"github": "GitHub",
@@ -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"
}
}
@@ -60,7 +60,8 @@
"settings": "Pengaturan",
"api": "API",
"activity": "Aktivitas",
"overview": "Ikhtisar"
"overview": "Ikhtisar",
"thisMachine": "Mesin ini"
},
"footer": {
"github": "GitHub",
@@ -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"
}
}
@@ -59,7 +59,8 @@
"api": "API",
"middleware": "Middleware",
"activity": "Attività",
"overview": "Panoramica"
"overview": "Panoramica",
"thisMachine": "Questa macchina"
},
"footer": {
"github": "GitHub",
@@ -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": "설정 숨기기"
}
}
@@ -59,7 +59,8 @@
"settings": "설정",
"api": "API",
"activity": "활동",
"overview": "개요"
"overview": "개요",
"thisMachine": "이 머신"
},
"footer": {
"github": "GitHub",
@@ -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"
}
}
@@ -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",
@@ -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": "隐藏设置"
}
}
@@ -59,7 +59,8 @@
"api": "API",
"middleware": "Middleware",
"activity": "活动",
"overview": "概览"
"overview": "概览",
"thisMachine": "本机"
},
"footer": {
"github": "GitHub",
+27 -1
View File
@@ -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; }
@@ -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
}
@@ -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 (
<article className={`fleet-gauge fleet-gauge--${tone} fleet-overview__cell`} aria-label={`${label} capacity`}>
@@ -31,8 +33,8 @@ function CapacityGauge({ label, metric, cpu = false, tone }) {
</div>
<div className="fleet-gauge__value-text">{reporting ? value : 'No data'}</div>
<div className="fleet-gauge__detail">{available}</div>
<span className="sr-only">Capacity coverage: {metric.reportingCount} of {metric.reportingCount + metric.unknownCount} nodes reporting; {metric.unknownCount} unknown.</span>
{metric.unknownCount > 0 && <div className="fleet-gauge__coverage">{metric.unknownCount} node{metric.unknownCount === 1 ? '' : 's'} unavailable</div>}
{!single && <span className="sr-only">Capacity coverage: {metric.reportingCount} of {metric.reportingCount + metric.unknownCount} nodes reporting; {metric.unknownCount} unknown.</span>}
{!single && metric.unknownCount > 0 && <div className="fleet-gauge__coverage">{metric.unknownCount} node{metric.unknownCount === 1 ? '' : 's'} unavailable</div>}
</article>
)
}
@@ -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 (
<div className="fleet-health fleet-overview__cell host-memory" aria-label="Running models summary" aria-live="polite">
<span className="fleet-kicker">This machine</span>
<div className="fleet-health__headline">
<strong>{models.length} running</strong>
<span>{modelBytes > 0 ? `${formatBytes(modelBytes)} resident${ramTotal > 0 ? ` of ${formatBytes(ramTotal)} RAM` : ''}` : 'no memory readings yet'}</span>
</div>
<svg className="fleet-health__bar host-memory__bar" viewBox="0 0 100 4" preserveAspectRatio="none" role="img"
aria-label={measured.map(model => `${model.model_name} ${formatBytes(model.rss_bytes)}`).join(', ') || 'No models using memory'}>
<rect className="host-memory__track" x="0" y="0" width="100" height="4" />
{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 <rect key={model.model_name} className={`host-memory__segment host-memory__segment--${index % SEGMENTS}`} x={start} y="0" width={width} height="4"><title>{`${model.model_name}: ${formatBytes(model.rss_bytes)}`}</title></rect>
})}
</svg>
<div className="fleet-health__legend">
{measured.slice(0, named).map((model, index) => (
<div key={model.model_name}>
<span title={model.model_name}><i className={`fleet-health__dot host-memory__dot--${index % SEGMENTS}`} /><span className="host-memory__name">{model.model_name}</span></span>
<strong>{formatBytes(model.rss_bytes)}</strong>
</div>
))}
{measured.length > named && <div><span>Others</span><strong>{measured.length - named} more</strong></div>}
</div>
</div>
)
}
export default function HostOverview({ summary, models, ramTotal }) {
return (
<section className="fleet-overview host-overview" aria-label="Host overview" data-testid="host-overview">
<MemoryShare models={models} ramTotal={ramTotal} />
<CapacityGauge label="VRAM" metric={summary.vram} tone="vram" single noDataText="No GPU detected" />
<CapacityGauge label="RAM" metric={summary.ram} tone="ram" single />
<CapacityGauge label="CPU" metric={summary.cpu} cpu tone="cpu" single />
<CapacityGauge label="Models disk" metric={summary.disk} tone="disk" single />
</section>
)
}
@@ -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 (
<div className="page page--wide nodes-fleet-page local-machine-page" data-testid="local-machine">
<PageHeader className="nodes-fleet-page__header" eyebrow={null} title={t('localMachine.title')} supporting={t('localMachine.subtitle')}
actions={<button type="button" className="btn btn-secondary btn-sm" aria-expanded={showScaleOut} onClick={() => setShowScaleOut(value => !value)}>
<i className="fas fa-network-wired" aria-hidden="true" /> {showScaleOut ? t('localMachine.hideScaleOut') : t('localMachine.scaleOut')}
</button>} />
{showScaleOut && scaleOut}
<HostOverview summary={summary} models={machine.rows} ramTotal={machine.resources?.ram?.total} />
<LocalRunningModels machine={machine} addToast={addToast} />
</div>
)
}
@@ -0,0 +1,85 @@
import ActionMenu from '../ActionMenu'
import { SortButton } from './ModelFleetTable'
import { formatBytes } from './nodeStatus'
import { uptime } from '../../utils/localHost'
// The single-node counterpart of ModelFleetTable. A cluster row is about
// placement (replicas, nodes, in-flight work); a row here is about one
// process on this host, so the columns are what that process is costing.
function UsageCell({ percent, label, tone, title, emptyLabel = 'No data' }) {
if (percent == null) return <span className="fleet-table__unknown" aria-label={title} title={title}>{emptyLabel}</span>
const clamped = Math.min(100, Math.max(0, percent))
return (
<div className={`fleet-table__resource fleet-table__resource--${tone}`} aria-label={title}>
<progress className="fleet-table__resource-track" max="100" value={clamped} aria-hidden="true" />
<span>{label}</span>
</div>
)
}
export default function LocalModelTable({ models, onViewLogs, onStop, stoppingName, sort, onSortChange, now = Date.now() }) {
return (
<div className="fleet-table-wrap model-fleet-table-wrap">
<table className="fleet-table model-fleet-table local-model-table" aria-label="Models running on this machine">
<thead><tr>
<th><SortButton column="model_name" label="Model" sort={sort} onSortChange={onSortChange} /></th>
<th><SortButton column="backend" label="Backend" sort={sort} onSortChange={onSortChange} /></th>
<th><SortButton column="rss_bytes" label="Memory" sort={sort} onSortChange={onSortChange} /></th>
<th><SortButton column="cpu_percent" label="CPU" sort={sort} onSortChange={onSortChange} /></th>
<th><SortButton column="started_at" label="Up for" sort={sort} onSortChange={onSortChange} /></th>
<th className="model-fleet-table__actions"><span className="sr-only">Actions</span></th>
</tr></thead>
<tbody>{models.map(model => {
const up = uptime(model.started_at, now)
const stopping = stoppingName === model.model_name
return (
<tr key={model.model_name} className={`fleet-table__row${stopping ? ' is-stopping' : ''}`} data-testid="local-model-row">
<td>
<span className="fleet-table__node">{model.model_name}</span>
{model.pid != null && <span className="fleet-table__subvalue">PID {model.pid}</span>}
</td>
<td><div className="model-backend-list">{model.backend ? <span>{model.backend}</span> : <span className="fleet-table__unknown">Unknown</span>}</div></td>
<td>
<UsageCell percent={model.memory_percent} tone="ram"
label={model.rss_bytes != null ? formatBytes(model.rss_bytes) : null}
title={model.rss_bytes != null ? `${formatBytes(model.rss_bytes)} resident, ${model.memory_percent?.toFixed(1)}% of host RAM` : 'Memory not reported'} />
</td>
<td>
{/* CPU is a delta between two server readings, so a process seen
for the first time has none yet; that is not "no data". */}
<UsageCell percent={model.cpu_percent} tone="cpu"
label={model.cpu_percent != null ? `${model.cpu_percent.toFixed(1)}%` : null}
emptyLabel={model.pid != null ? 'Measuring…' : 'No data'}
title={model.cpu_percent != null ? `${model.cpu_percent.toFixed(1)}% of host CPU` : model.pid != null ? 'CPU not measured yet' : 'CPU not reported'} />
</td>
<td>{up ?? <span className="fleet-table__unknown">Unknown</span>}</td>
<td className="model-fleet-table__actions">
<ActionMenu
compact
ariaLabel={`${model.model_name} actions`}
triggerLabel={`Actions for ${model.model_name}`}
items={[{
key: 'logs',
icon: 'fa-terminal',
label: 'View logs',
onClick: () => onViewLogs(model),
}, {
divider: true,
}, {
key: 'stop',
icon: 'fa-stop',
label: stopping ? 'Stopping…' : 'Stop model…',
danger: true,
disabled: !!stoppingName,
onClick: invoker => onStop(model, invoker),
}]}
/>
</td>
</tr>
)
})}</tbody>
</table>
</div>
)
}
@@ -0,0 +1,93 @@
import { useMemo, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { backendControlApi } from '../../utils/api'
import { filterLocalModels, sortLocalModels } from '../../utils/localHost'
import ConfirmDialog from '../ConfirmDialog'
import LoadingSpinner from '../LoadingSpinner'
import LocalModelTable from './LocalModelTable'
// Running models on this machine: search, sort, logs and stop. Takes the
// polled data from useLocalMachine rather than fetching it, so a page that
// also draws gauges from the same poll does not ask twice.
//
// `limit` turns it into a preview (the Operate overview): the heaviest models
// first, no search, and a link to the full view.
export default function LocalRunningModels({ machine, addToast, limit, moreHref }) {
const navigate = useNavigate()
const { rows, state, error, refresh } = machine
const [query, setQuery] = useState('')
const [sort, setSort] = useState(limit ? { key: 'rss_bytes', direction: 'desc' } : { key: 'model_name', direction: 'asc' })
const [confirmStop, setConfirmStop] = useState(null)
const [stoppingName, setStoppingName] = useState(null)
const stoppingRef = useRef(false)
const invokerRef = useRef(null)
const visible = useMemo(() => {
const ordered = sortLocalModels(filterLocalModels(rows, limit ? '' : query), sort)
return limit ? ordered.slice(0, limit) : ordered
}, [rows, query, sort, limit])
const promptStop = (model, invoker) => {
invokerRef.current = invoker
setConfirmStop(model)
}
const cancelStop = () => {
setConfirmStop(null)
requestAnimationFrame(() => invokerRef.current?.focus())
}
const stop = async () => {
const model = confirmStop
if (!model || stoppingRef.current) return
stoppingRef.current = true
setStoppingName(model.model_name)
try {
await backendControlApi.shutdown({ model: model.model_name })
addToast?.(`Stopped ${model.model_name}`, 'success')
} catch (err) {
addToast?.(`Could not stop ${model.model_name}: ${err.message || err}`, 'error')
} finally {
await refresh()
setConfirmStop(null)
setStoppingName(null)
stoppingRef.current = false
}
}
const hidden = limit ? Math.max(0, rows.length - visible.length) : 0
return (
<div className="fleet-workbench local-running" data-testid="local-running-models">
{/* A preview sits under its own section heading, which says this. */}
{!limit && <div className="model-workbench__scope">
<div><strong>Running models</strong><span>Loaded on this machine, with the memory and CPU each backend process is using</span></div>
{state === 'loaded' && <span aria-live="polite">{rows.length} running</span>}
</div>}
{state === 'loading' && <div className="model-workbench__state" role="status"><LoadingSpinner size="sm" /><strong>Loading running models</strong></div>}
{state === 'error' && <div className="model-workbench__state model-workbench__state--error" role="alert"><i className="fas fa-triangle-exclamation" aria-hidden="true" /><strong>Unable to load running models</strong><span>{error}</span><button type="button" className="btn btn-secondary btn-sm" onClick={() => refresh()}>Retry</button></div>}
{state === 'loaded' && rows.length === 0 && (
<div className="model-workbench__state local-running__empty" data-testid="local-running-empty">
<i className="fas fa-layer-group" aria-hidden="true" />
<strong>No models running</strong>
<span>A model loads on its first request, or when you start it from <Link to="/app/models?view=installed">Models</Link>. It will appear here while it is in memory.</span>
</div>
)}
{state === 'loaded' && rows.length > 0 && <>
{!limit && <div className="model-toolbar"><input className="input fleet-toolbar__search" type="search" aria-label="Search running models" placeholder="Search model or backend…" value={query} onChange={event => setQuery(event.target.value)} /></div>}
<LocalModelTable models={visible} sort={sort} onSortChange={setSort} stoppingName={stoppingName}
onViewLogs={model => navigate(`/app/backend-logs/${encodeURIComponent(model.model_name)}`)}
onStop={promptStop} />
{moreHref && (
<div className="fleet-pagination local-running__more">
{hidden > 0 && <span>{hidden} more not shown</span>}
<Link to={moreHref} className="btn btn-secondary btn-sm">Open this machine <i className="fas fa-arrow-right" aria-hidden="true" /></Link>
</div>
)}
</>}
<ConfirmDialog open={!!confirmStop} title={confirmStop ? `Stop ${confirmStop.model_name}?` : 'Stop model?'}
message={confirmStop ? `This stops the ${confirmStop.backend || 'backend'} process serving ${confirmStop.model_name} and frees its memory. The next request that uses it loads it again.` : ''}
confirmLabel="Stop model" pendingLabel="Stopping…" pending={!!stoppingName} danger onConfirm={stop} onCancel={cancelStop} />
</div>
)
}
@@ -1,7 +1,7 @@
import { timeAgo } from './nodeStatus'
import ActionMenu from '../ActionMenu'
function SortButton({ column, label, sort, onSortChange }) {
export function SortButton({ column, label, sort, onSortChange }) {
const active = sort.key === column
const nextDirection = active && sort.direction === 'asc' ? 'desc' : 'asc'
return (
@@ -1,5 +1,5 @@
import { createContext, useContext, useState, useCallback, useMemo } from 'react'
import { backendsApi, modelsApi, nodesApi, resourcesApi, tracesApi } from '../utils/api'
import { backendsApi, modelsApi, nodesApi, resourcesApi, systemApi, tracesApi } from '../utils/api'
import { usePolling } from '../hooks/usePolling'
import { useOperations } from '../hooks/useOperations'
import { useDistributedMode } from '../hooks/useDistributedMode'
@@ -43,14 +43,15 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
const [resourcesLoaded, setResourcesLoaded] = useState(false)
const [traces, setTraces] = useState(null)
const [installed, setInstalled] = useState({ backends: null, models: null })
const [running, setRunning] = useState(null)
const { operations } = useOperations()
// The cluster API answers 503 when distributed mode is off, so asking for it
// on a single-node install is a guaranteed miss on every tick. The rail gates
// the Nodes entry the same way.
const { enabled: distributed } = useDistributedMode()
const { enabled: distributed, loading: distributedLoading } = useDistributedMode()
const fetchSummary = useCallback(async () => {
const [u, n, r, tr, bi, mi] = await Promise.all([
const [u, n, r, tr, bi, mi, sys] = await Promise.all([
// GET /api/backends/upgrades returns the upgrade checker's cached view.
// Never POST /upgrades/check on a timer — that forces a real registry
// check.
@@ -60,6 +61,9 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
settle(tracesApi.summary(), null),
settle(backendsApi.listInstalled?.() ?? Promise.resolve(null), null),
settle(modelsApi.listCapabilities(), null),
// Only this process's loader: on a distributed controller the models
// live on workers, and the Nodes entry carries the cluster signal.
distributed ? Promise.resolve(null) : settle(systemApi.info(), null),
])
setUpgrades(u && typeof u === 'object' ? u : {})
setNodes(Array.isArray(n) ? n : (n?.nodes || []))
@@ -70,9 +74,12 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
backends: Array.isArray(bi) ? bi.length : (bi?.backends?.length ?? null),
models: mi?.data?.length ?? null,
})
setRunning(Array.isArray(sys?.loaded_models) ? sys.loaded_models.length : null)
}, [distributed])
usePolling(fetchSummary, pollInterval)
// Wait for the cluster probe: several sources depend on the mode, and a
// first tick taken before it answers would ask a cluster for local state.
usePolling(fetchSummary, pollInterval, { enabled: !distributedLoading })
const value = useMemo(() => {
const upgradeList = Object.values(upgrades || {})
@@ -113,6 +120,9 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
operations,
traces,
installed,
// null until the cluster probe answers, so a consumer can wait rather
// than render the single-node view on a cluster for one frame.
distributed: distributedLoading ? null : distributed,
attention,
signals: {
attention: attention.length || null,
@@ -122,9 +132,10 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
host: memoryPercent(resources),
traces: traces?.errors || null,
usage: traces?.total ? compact(traces.total) : null,
running: running || null,
},
}
}, [upgrades, nodes, resources, resourcesLoaded, operations, traces, installed])
}, [upgrades, nodes, resources, resourcesLoaded, operations, traces, installed, running, distributed, distributedLoading])
return (
<OperateSummaryContext.Provider value={value}>
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useMemo, useState } from 'react'
import { resourcesApi, systemApi } from '../utils/api'
import { localModelRows } from '../utils/localHost'
import { usePolling } from './usePolling'
// Models loaded by this LocalAI process, plus (optionally) the host readings
// the capacity gauges draw from.
//
// Five seconds, like the Nodes page's own poll: this is a surface people act
// on, and a model they just stopped should leave the list while they are still
// looking at it. The per-process CPU share is a delta between two server-side
// readings, so the poll interval is also the window that number covers.
//
// `state` is 'loading' until the first answer, then 'loaded' or 'error'. A
// failed refresh after a good one keeps the last rows on screen: a transient
// error should not blank a table someone is reading.
export function useLocalMachine({ withResources = true, intervalMs = 5000, enabled = true } = {}) {
const [system, setSystem] = useState(null)
const [resources, setResources] = useState(null)
const [state, setState] = useState('loading')
const [error, setError] = useState('')
const fetchAll = useCallback(async () => {
const [sys, res] = await Promise.allSettled([
systemApi.info(),
withResources ? resourcesApi.get() : Promise.resolve(null),
])
if (sys.status === 'fulfilled') {
setSystem(sys.value)
setState('loaded')
setError('')
} else {
setError(sys.reason?.message || 'Unable to read loaded models')
setState(current => (current === 'loaded' ? current : 'error'))
}
if (res.status === 'fulfilled') setResources(res.value)
}, [withResources])
const { refetch } = usePolling(fetchAll, intervalMs, { enabled })
const rows = useMemo(() => localModelRows(system), [system])
return { rows, resources, state, error, refresh: refetch }
}
+15 -11
View File
@@ -11,6 +11,7 @@ import NodeFleetTable from '../components/nodes/NodeFleetTable'
import NodeInspector from '../components/nodes/NodeInspector'
import ModelFleetTable from '../components/nodes/ModelFleetTable'
import ModelInspector from '../components/nodes/ModelInspector'
import LocalMachineView from '../components/nodes/LocalMachineView'
import ImageSelector, { dockerFlags, dockerImage, useImageSelector } from '../components/ImageSelector'
function CommandBlock({ command, addToast }) {
@@ -50,16 +51,15 @@ function WorkerHintCard({ addToast, nodeType = 'backend', hasWorkers }) {
)
}
function DisabledState({ addToast }) {
// The route to more than one machine, shown from the single-node view on
// request rather than as the whole page.
function ScaleOutCard({ addToast }) {
return (
<div className="page page--wide">
<div className="p2p-hero"><i className="fas fa-network-wired" /><h1>Distributed Mode Not Enabled</h1><p>Enable distributed mode to manage backend nodes across multiple machines and route inference across the fleet.</p></div>
<div className="card p2p-enable pad-lg">
<h3 className="panel-title"><i className="fas fa-rocket text-accent" />How to Enable Distributed Mode</h3>
<p className="form-label">Start LocalAI with distributed mode</p>
<CommandBlock command={'local-ai run --distributed \\\n --distributed-db "postgres://user:pass@host/db" \\\n --distributed-nats "nats://host:4222"'} addToast={addToast} />
<p className="text-note mt-md">Then register a worker and refresh this page. See the <a href="https://localai.io/features/distributed-mode/" target="_blank" rel="noopener noreferrer" className="text-primary">Distributed Mode documentation</a> for production setup.</p>
</div>
<div className="card p2p-enable pad-lg mb-xl" data-testid="scale-out">
<h3 className="panel-title"><i className="fas fa-rocket text-accent" />Distributed mode is not enabled</h3>
<p className="text-base text-secondary mb-md">Distributed mode spreads models across worker machines and routes inference across the fleet. Start LocalAI with it enabled, then register a worker.</p>
<CommandBlock command={'local-ai run --distributed \\\n --distributed-db "postgres://user:pass@host/db" \\\n --distributed-nats "nats://host:4222"'} addToast={addToast} />
<p className="text-note mt-md">See the <a href="https://localai.io/features/distributed-mode/" target="_blank" rel="noopener noreferrer" className="text-primary">Distributed Mode documentation</a> for production setup.</p>
</div>
)
}
@@ -126,7 +126,11 @@ export default function Nodes() {
})
setEnabled(true)
} catch (error) {
if (error.message?.includes('503') || error.message?.includes('Service Unavailable')) setEnabled(false)
// A single-node server never registers the cluster routes, so it
// answers 404 here; 503 is what the routes say when mounted without a
// registry. Treating only 503 as "not distributed" sent every
// single-node install to the empty worker-registration card.
if (error.status === 404 || error.status === 503 || error.message?.includes('503') || error.message?.includes('Service Unavailable')) setEnabled(false)
} finally {
setLoading(false)
}
@@ -329,7 +333,7 @@ export default function Nodes() {
}
if (loading) return <div className="page page--wide loading-center"><LoadingSpinner size="lg" /></div>
if (!enabled) return <DisabledState addToast={addToast} />
if (!enabled) return <LocalMachineView addToast={addToast} scaleOut={<ScaleOutCard addToast={addToast} />} />
if (nodes.length === 0) return (
<div className="page page--wide">
<PageHeader title={t('nodes.title')} supporting={t('nodes.subtitle')} />
@@ -1,8 +1,10 @@
import { Link } from 'react-router-dom'
import { Link, useOutletContext } 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 LocalRunningModels from '../components/nodes/LocalRunningModels'
import { useLocalMachine } from '../hooks/useLocalMachine'
import { useOperateSummary } from '../contexts/OperateSummaryContext'
import { staggerStyle } from '../hooks/useStagger'
@@ -33,6 +35,7 @@ export default function OperateOverview() {
const upgradeCount = Object.keys(summary?.upgrades || {}).length
const traces = summary?.traces
const installed = summary?.installed || { backends: null, models: null }
const distributed = summary?.distributed
return (
<div className="page-pad" data-testid="operate-overview">
@@ -101,6 +104,16 @@ export default function OperateOverview() {
/>
</section>
<section>
<div className="lane-head"><h2>{t('operate.overview.running.heading')}</h2></div>
{distributed === false && <RunningHere />}
{distributed === true && (
<ul className="lanes">
<OperateSection to="/app/nodes" label={t('operate.overview.running.cluster')} summary={t('operate.overview.running.clusterSummary')} />
</ul>
)}
</section>
<section>
<div className="lane-head"><h2>{t('operate.overview.attention.heading')}</h2></div>
{attention.length === 0 ? (
@@ -178,6 +191,14 @@ export default function OperateOverview() {
)
}
// Its own component so the 5s poll of /system only runs once the page knows it
// is on a single node, and stops when the overview is left.
function RunningHere() {
const { addToast } = useOutletContext() || {}
const machine = useLocalMachine({ withResources: false })
return <LocalRunningModels machine={machine} addToast={addToast} limit={5} moreHref="/app/nodes" />
}
function HeadlineStat({ label, value, series, tone, index = 0 }) {
return (
<div className="operate-headline__cell" style={staggerStyle(index)}>
+99
View File
@@ -0,0 +1,99 @@
// Adapters that let a single-node install reuse the Nodes page's fleet
// widgets. The gauges and summaries there are written against worker heartbeat
// fields (total_vram, available_ram, cpu_usage_percent...), and GET
// /api/resources reports the same readings for this host under other names.
// Translating the host into one healthy "node" keeps a single implementation of
// the capacity maths instead of a second copy that drifts.
function finite(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
export function hostAsNode(resources) {
if (!resources || typeof resources !== 'object') return null
const node = { id: 'local', name: 'This machine', status: 'healthy' }
const gpus = Array.isArray(resources.gpus) ? resources.gpus : []
if (resources.type === 'gpu' && gpus.length > 0) {
node.total_vram = gpus.reduce((sum, gpu) => sum + (finite(gpu?.total_vram) ?? 0), 0)
node.available_vram = gpus.reduce((sum, gpu) => sum + (finite(gpu?.free_vram) ?? 0), 0)
}
const ram = resources.ram
if (ram && finite(ram.total) > 0) {
node.total_ram = ram.total
// `available` counts reclaimable page cache, `free` does not. The worker
// heartbeat reports `available`, so the gauges agree across both modes.
node.available_ram = finite(ram.available) ?? finite(ram.free)
}
const cpu = resources.cpu
if (cpu && finite(cpu.logical_cores) > 0) {
node.cpu_logical_cores = cpu.logical_cores
node.cpu_usage_percent = cpu.usage_percent
node.cpu_load_1 = cpu.load_1
}
const disk = resources.disk
if (disk && finite(disk.total) > 0) {
node.total_disk = disk.total
node.available_disk = disk.available
}
return node
}
// One row per loaded model from GET /system. The process block is absent when
// the model has no local process, so every derived number stays nullable
// rather than defaulting to a 0 that would read as "idle" or "empty".
export function localModelRows(systemInfo) {
const loaded = Array.isArray(systemInfo?.loaded_models) ? systemInfo.loaded_models : []
return loaded
.filter(model => typeof model?.id === 'string' && model.id.trim())
.map(model => {
const proc = model.process || null
return {
model_name: model.id,
backend: model.backend || '',
pid: finite(proc?.pid),
rss_bytes: finite(proc?.rss_bytes),
memory_percent: finite(proc?.memory_percent),
cpu_percent: finite(proc?.cpu_percent),
started_at: proc?.started_at || null,
}
})
}
export function filterLocalModels(rows, query) {
const needle = String(query || '').trim().toLowerCase()
if (!needle) return rows
return rows.filter(row => row.model_name.toLowerCase().includes(needle) || row.backend.toLowerCase().includes(needle))
}
// Unknown values sort last in both directions: a model with no reading is not
// the smallest one, it is the one we know least about.
export function sortLocalModels(rows, { key, direction }) {
const factor = direction === 'desc' ? -1 : 1
const value = row => (key === 'started_at' ? (row.started_at ? Date.parse(row.started_at) : null) : row[key])
return [...rows].sort((left, right) => {
const a = value(left)
const b = value(right)
if (a == null && b == null) return left.model_name.localeCompare(right.model_name)
if (a == null) return 1
if (b == null) return -1
if (typeof a === 'string') return a.localeCompare(b) * factor
return (a - b) * factor
})
}
export function uptime(startedAt, now = Date.now()) {
const started = startedAt ? Date.parse(startedAt) : NaN
if (!Number.isFinite(started)) return null
const seconds = Math.max(0, Math.floor((now - started) / 1000))
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ${minutes % 60}m`
return `${Math.floor(hours / 24)}d ${hours % 24}h`
}
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { filterLocalModels, hostAsNode, localModelRows, sortLocalModels, uptime } from './localHost.js'
import { summarizeFleet } from './nodeFleet.js'
const GB = 1024 ** 3
test('a GPU host becomes one healthy node the fleet gauges can read', () => {
const node = hostAsNode({
type: 'gpu',
gpus: [{ total_vram: 24 * GB, free_vram: 20 * GB }, { total_vram: 24 * GB, free_vram: 4 * GB }],
ram: { total: 64 * GB, available: 48 * GB, free: 8 * GB },
cpu: { logical_cores: 16, usage_percent: 25, load_1: 3.5 },
disk: { total: 1000 * GB, available: 250 * GB },
})
const summary = summarizeFleet([node])
assert.equal(summary.health.healthy, 1)
assert.equal(summary.vram.total, 48 * GB)
assert.equal(summary.vram.available, 24 * GB)
assert.equal(summary.ram.available, 48 * GB, 'uses available, not free, like the worker heartbeat')
assert.equal(summary.cpu.totalLogicalCores, 16)
assert.equal(summary.cpu.busyCoreEquivalents, 4)
assert.equal(summary.disk.used, 750 * GB)
})
test('a CPU-only host reports no VRAM instead of zero VRAM', () => {
const summary = summarizeFleet([hostAsNode({ type: 'ram', gpus: [], ram: { total: 16 * GB, available: 8 * GB } })])
assert.equal(summary.vram.reportingCount, 0)
assert.equal(summary.ram.reportingCount, 1)
assert.equal(summary.cpu.reportingCount, 0, 'no cpu block means no data, not an idle CPU')
})
test('no resources means no node', () => {
assert.equal(hostAsNode(null), null)
})
test('loaded models keep unknown process readings as null', () => {
const rows = localModelRows({
loaded_models: [
{ id: 'qwen', backend: 'llama-cpp', process: { pid: 42, rss_bytes: 2 * GB, memory_percent: 3.1, cpu_percent: 12.5, started_at: '2026-09-21T10:00:00Z' } },
{ id: 'whisper' },
{ id: '' },
],
})
assert.equal(rows.length, 2)
assert.deepEqual(rows[0], { model_name: 'qwen', backend: 'llama-cpp', pid: 42, rss_bytes: 2 * GB, memory_percent: 3.1, cpu_percent: 12.5, started_at: '2026-09-21T10:00:00Z' })
assert.equal(rows[1].rss_bytes, null)
assert.equal(rows[1].cpu_percent, null)
assert.equal(rows[1].backend, '')
})
test('sorting puts models without a reading last in both directions', () => {
const rows = [
{ model_name: 'a', backend: '', rss_bytes: 1 },
{ model_name: 'b', backend: '', rss_bytes: null },
{ model_name: 'c', backend: '', rss_bytes: 3 },
]
assert.deepEqual(sortLocalModels(rows, { key: 'rss_bytes', direction: 'desc' }).map(r => r.model_name), ['c', 'a', 'b'])
assert.deepEqual(sortLocalModels(rows, { key: 'rss_bytes', direction: 'asc' }).map(r => r.model_name), ['a', 'c', 'b'])
})
test('filtering matches model name or backend', () => {
const rows = [{ model_name: 'Qwen3', backend: 'llama-cpp' }, { model_name: 'kokoro', backend: 'kokoro' }]
assert.deepEqual(filterLocalModels(rows, 'LLAMA').map(r => r.model_name), ['Qwen3'])
assert.equal(filterLocalModels(rows, ' ').length, 2)
})
test('uptime reads at the right grain', () => {
const now = Date.parse('2026-09-21T12:00:00Z')
assert.equal(uptime('2026-09-21T11:59:30Z', now), '30s')
assert.equal(uptime('2026-09-21T09:15:00Z', now), '2h 45m')
assert.equal(uptime('2026-09-19T11:00:00Z', now), '2d 1h')
assert.equal(uptime(null, now), null)
})
+1 -1
View File
@@ -443,7 +443,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
})
})
router.GET("/system", localai.SystemInformations(cl, ml, appConfig), adminMiddleware)
router.GET("/system", localai.SystemInformations(cl, ml, appConfig, monitoring.NewLocalProcessSampler()), adminMiddleware)
// misc
tokenizeHandler := localai.TokenizeEndpoint(cl, ml, appConfig)
+16
View File
@@ -1897,6 +1897,22 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"watchdog_interval": watchdogInterval,
}
// The same host readings a distributed worker reports in its
// heartbeat, so a single-node install can draw the CPU and models-disk
// gauges the Nodes page draws for a cluster. Each is omitted on a
// failed read rather than zeroed: 0 cores or 0 free bytes would be a
// claim, not an absence.
if cpuInfo, err := xsysinfo.GetCPUInfo(); err == nil {
response["cpu"] = map[string]any{
"logical_cores": cpuInfo.LogicalCores,
"usage_percent": cpuInfo.UsagePercent,
"load_1": cpuInfo.Load1,
}
}
if diskInfo, err := xsysinfo.GetDiskInfo(appConfig.SystemState.Model.ModelsPath); err == nil {
response["disk"] = diskInfo
}
// An additional field, never a rewrite of the local aggregate above:
// the resource monitor reports this controller's genuine own usage, and
// only the model-sizing surfaces read the cluster block.
+17
View File
@@ -204,6 +204,23 @@ type SysInfoModel struct {
// so it is resolved from the model's config; empty when the model was
// loaded without one (a loose file, or a config since removed).
Backend string `json:"backend,omitempty"`
// Process is the backend process serving the model on this host. Absent
// when the model has no local process (a distributed worker holds it) or
// the process could not be read.
Process *SysInfoProcess `json:"process,omitempty"`
}
// SysInfoProcess is a point-in-time reading of one backend process.
type SysInfoProcess struct {
PID int32 `json:"pid"`
// RSSBytes is resident host memory. Weights offloaded to a GPU are not
// in it.
RSSBytes uint64 `json:"rss_bytes"`
MemoryPercent float32 `json:"memory_percent"`
// CPUPercent is the share of the whole host's CPU used since the previous
// reading, 0-100. Absent on the first reading of a process.
CPUPercent *float64 `json:"cpu_percent,omitempty"`
StartedAt time.Time `json:"started_at,omitzero"`
}
type SystemInformationResponse struct {
@@ -0,0 +1,77 @@
package monitoring
import (
"sync"
"time"
"github.com/mudler/LocalAI/core/schema"
gopsutil "github.com/shirou/gopsutil/v3/process"
)
// LocalProcessSampler reads the resource use of backend processes running on
// this host.
//
// It keeps one gopsutil handle per PID between calls because a process's CPU
// share is a delta between two readings. A fresh handle on every request can
// only report the lifetime average, which for a model loaded hours ago says
// nothing about what it is doing now.
type LocalProcessSampler struct {
mu sync.Mutex
procs map[int32]*gopsutil.Process
}
func NewLocalProcessSampler() *LocalProcessSampler {
return &LocalProcessSampler{procs: map[int32]*gopsutil.Process{}}
}
// Sample reads memory, CPU and start time for pid. CPUPercent stays nil on the
// first reading of a PID: there is no earlier sample to take a delta against,
// and reporting 0 would read as "idle" rather than "not measured yet".
func (s *LocalProcessSampler) Sample(pid int32) (*schema.SysInfoProcess, error) {
s.mu.Lock()
defer s.mu.Unlock()
proc, seen := s.procs[pid]
if !seen {
var err error
proc, err = gopsutil.NewProcess(pid)
if err != nil {
return nil, err
}
s.procs[pid] = proc
}
mem, err := proc.MemoryInfo()
if err != nil {
delete(s.procs, pid)
return nil, err
}
out := &schema.SysInfoProcess{PID: pid, RSSBytes: mem.RSS}
if pct, err := proc.MemoryPercent(); err == nil {
out.MemoryPercent = pct
}
if created, err := proc.CreateTime(); err == nil {
out.StartedAt = time.UnixMilli(created).UTC()
}
// Percent(0) measures against the previous call on this handle and
// returns 0 on the first one, which is exactly the case to leave unset.
if cpu, err := proc.Percent(0); err == nil && seen {
out.CPUPercent = &cpu
}
return out, nil
}
// Retain forgets every PID not in live, so handles for unloaded models do not
// accumulate and a recycled PID starts from a clean reading.
func (s *LocalProcessSampler) Retain(live map[int32]struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
for pid := range s.procs {
if _, ok := live[pid]; !ok {
delete(s.procs, pid)
}
}
}
@@ -0,0 +1,79 @@
package monitoring
import (
"os/exec"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LocalProcessSampler", func() {
var (
sampler *LocalProcessSampler
cmd *exec.Cmd
pid int32
)
BeforeEach(func() {
sampler = NewLocalProcessSampler()
cmd = exec.Command("sleep", "30")
Expect(cmd.Start()).To(Succeed())
pid = int32(cmd.Process.Pid)
})
AfterEach(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
})
It("reads resident memory and start time of a live process", func() {
proc, err := sampler.Sample(pid)
Expect(err).ToNot(HaveOccurred())
Expect(proc.PID).To(Equal(pid))
Expect(proc.RSSBytes).To(BeNumerically(">", 0))
Expect(proc.StartedAt).To(BeTemporally("~", time.Now(), time.Minute))
})
It("leaves CPU unset on the first reading and reports it once there is a delta", func() {
first, err := sampler.Sample(pid)
Expect(err).ToNot(HaveOccurred())
Expect(first.CPUPercent).To(BeNil())
second, err := sampler.Sample(pid)
Expect(err).ToNot(HaveOccurred())
Expect(second.CPUPercent).ToNot(BeNil())
Expect(*second.CPUPercent).To(BeNumerically(">=", 0))
})
It("starts a PID over after Retain drops it", func() {
_, err := sampler.Sample(pid)
Expect(err).ToNot(HaveOccurred())
sampler.Retain(map[int32]struct{}{})
again, err := sampler.Sample(pid)
Expect(err).ToNot(HaveOccurred())
Expect(again.CPUPercent).To(BeNil())
})
It("keeps a PID that is still live across Retain", func() {
_, err := sampler.Sample(pid)
Expect(err).ToNot(HaveOccurred())
sampler.Retain(map[int32]struct{}{pid: {}})
again, err := sampler.Sample(pid)
Expect(err).ToNot(HaveOccurred())
Expect(again.CPUPercent).ToNot(BeNil())
})
It("fails for a process that has exited", func() {
Expect(cmd.Process.Kill()).To(Succeed())
// Wait reaps it, so /proc no longer has the PID.
_, _ = cmd.Process.Wait()
_, err := sampler.Sample(pid)
Expect(err).To(HaveOccurred())
})
})
+33
View File
@@ -55,6 +55,39 @@ 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.
## Running now
On a single-node install the overview lists the models loaded on this machine,
heaviest first, up to five. Each row shows the backend, resident memory, CPU
share and uptime, with **View logs** and **Stop model…** in the row menu.
**Open this machine** leads to the full list.
With distributed mode on, models run on workers rather than on the controller,
so this section links to **Operate → Nodes → Running models** instead.
## This machine
On a single-node install, **Operate → This machine** (`/app/nodes`) shows the
host and everything loaded on it:
- **Capacity gauges** for VRAM, RAM, CPU and the models disk, the same gauges
the Nodes page draws for a cluster. A host without a GPU says so rather than
showing an empty VRAM gauge.
- **A memory bar** splitting host RAM by running model, so you can see which
model is holding memory.
- **Running models**: search, sort by memory, CPU or uptime, open a model's
logs, or stop it. Stopping asks for confirmation; the model loads again on
its next request.
The page polls `GET /system` and `GET /api/resources` every five seconds. The
per-model readings come from the `process` block of
[`GET /system`]({{% relref "reference/system-info" %}}); the host CPU and disk
readings come from the `cpu` and `disk` fields of `GET /api/resources`.
**Add machines** reveals the command to start LocalAI in distributed mode. Once
distributed mode is on, the same route becomes the Nodes page and the rail
entry moves to the Cluster group.
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
+22 -2
View File
@@ -21,6 +21,13 @@ Returns available backends and currently loaded models.
| `backends` | `array` | List of available backend names (strings) |
| `loaded_models` | `array` | List of currently loaded models |
| `loaded_models[].id` | `string` | Model identifier |
| `loaded_models[].backend` | `string` | Backend serving the model, from its config. Omitted when the model was loaded without one |
| `loaded_models[].process` | `object` | The backend process serving the model on this host. Omitted when there is no local process (a distributed worker holds the model) or it could not be read |
| `loaded_models[].process.pid` | `integer` | Process ID |
| `loaded_models[].process.rss_bytes` | `integer` | Resident host memory, in bytes. Weights offloaded to a GPU are not included |
| `loaded_models[].process.memory_percent` | `number` | `rss_bytes` as a percentage of host RAM |
| `loaded_models[].process.cpu_percent` | `number` | Share of the whole host's CPU used since the previous call, 0-100. Omitted on the first call that sees the process, because there is no earlier reading to compare against |
| `loaded_models[].process.started_at` | `string` | When the process started (RFC 3339) |
### Usage
@@ -40,15 +47,28 @@ curl http://localhost:8080/system
],
"loaded_models": [
{
"id": "my-llama-model"
"id": "my-llama-model",
"backend": "llama-cpp",
"process": {
"pid": 48213,
"rss_bytes": 5368709120,
"memory_percent": 7.8,
"cpu_percent": 42.5,
"started_at": "2026-09-21T09:12:44Z"
}
},
{
"id": "whisper-1"
"id": "whisper-1",
"backend": "whisper"
}
]
}
```
`cpu_percent` covers the time since the previous call to this endpoint, so a
dashboard polling every few seconds gets a current reading. The WebUI's
**Operate → This machine** page polls it every five seconds.
---
## Version