mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-21 21:54:52 -04:00
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:
1 parent
728e08c6c2
commit
c0b7e64973
41 files changed
+1165
-41
No files matched your search
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user