}
)
}
diff --git a/core/http/react-ui/src/components/nodes/HostOverview.jsx b/core/http/react-ui/src/components/nodes/HostOverview.jsx
new file mode 100644
index 000000000..6f347a5e7
--- /dev/null
+++ b/core/http/react-ui/src/components/nodes/HostOverview.jsx
@@ -0,0 +1,65 @@
+import { CapacityGauge } from './ClusterOverview'
+import { formatBytes } from './nodeStatus'
+
+// The single-node counterpart of ClusterOverview: the same four capacity
+// gauges, fed from this host instead of summed across workers. The lead cell
+// trades fleet health, which has nothing to say about one machine, for where
+// the machine's memory is going: one bar segment per running model.
+
+const SEGMENTS = 5
+const MIN_SEGMENT = 0.8
+// The legend grid has three columns: three models, or two and a count.
+const LEGEND_COLUMNS = 3
+
+function MemoryShare({ models, ramTotal }) {
+ const measured = models
+ .filter(model => model.rss_bytes != null && model.rss_bytes > 0)
+ .sort((left, right) => right.rss_bytes - left.rss_bytes)
+ const modelBytes = measured.reduce((sum, model) => sum + model.rss_bytes, 0)
+ const scale = ramTotal > 0 ? ramTotal : modelBytes
+ const named = measured.length > LEGEND_COLUMNS ? LEGEND_COLUMNS - 1 : measured.length
+ let cursor = 0
+
+ return (
+
+ )
+}
+
+export default function HostOverview({ summary, models, ramTotal }) {
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/nodes/LocalMachineView.jsx b/core/http/react-ui/src/components/nodes/LocalMachineView.jsx
new file mode 100644
index 000000000..9f95f5322
--- /dev/null
+++ b/core/http/react-ui/src/components/nodes/LocalMachineView.jsx
@@ -0,0 +1,36 @@
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import PageHeader from '../PageHeader'
+import HostOverview from './HostOverview'
+import LocalRunningModels from './LocalRunningModels'
+import { useLocalMachine } from '../../hooks/useLocalMachine'
+import { hostAsNode } from '../../utils/localHost'
+import { summarizeFleet } from '../../utils/nodeFleet'
+
+// What the Nodes page shows when distributed mode is off. It used to be only
+// an "enable distributed mode" card, which left a single-node install with no
+// page listing what was loaded and no way to stop it short of the API. The
+// host is treated as a fleet of one so the gauges and the running-models table
+// match what a cluster operator sees; the distributed setup moves behind a
+// button, since most single-node installs are single-node on purpose.
+export default function LocalMachineView({ addToast, scaleOut }) {
+ const { t } = useTranslation('admin')
+ const machine = useLocalMachine()
+ const [showScaleOut, setShowScaleOut] = useState(false)
+ const summary = useMemo(() => {
+ const node = hostAsNode(machine.resources)
+ return summarizeFleet(node ? [node] : [])
+ }, [machine.resources])
+
+ return (
+
+ )
+}
diff --git a/core/http/react-ui/src/components/nodes/LocalModelTable.jsx b/core/http/react-ui/src/components/nodes/LocalModelTable.jsx
new file mode 100644
index 000000000..81f7475dc
--- /dev/null
+++ b/core/http/react-ui/src/components/nodes/LocalModelTable.jsx
@@ -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 {emptyLabel}
+ const clamped = Math.min(100, Math.max(0, percent))
+ return (
+
)
}
@@ -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
- if (!enabled) return
+ if (!enabled) return } />
if (nodes.length === 0) return (
diff --git a/core/http/react-ui/src/pages/OperateOverview.jsx b/core/http/react-ui/src/pages/OperateOverview.jsx
index 6c5d99137..8a41df284 100644
--- a/core/http/react-ui/src/pages/OperateOverview.jsx
+++ b/core/http/react-ui/src/pages/OperateOverview.jsx
@@ -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 (
{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
+}
+
function HeadlineStat({ label, value, series, tone, index = 0 }) {
return (
diff --git a/core/http/react-ui/src/utils/localHost.js b/core/http/react-ui/src/utils/localHost.js
new file mode 100644
index 000000000..20e096467
--- /dev/null
+++ b/core/http/react-ui/src/utils/localHost.js
@@ -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`
+}
diff --git a/core/http/react-ui/src/utils/localHost.test.js b/core/http/react-ui/src/utils/localHost.test.js
new file mode 100644
index 000000000..5ca792781
--- /dev/null
+++ b/core/http/react-ui/src/utils/localHost.test.js
@@ -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)
+})
diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go
index 601d71c6c..a02da736d 100644
--- a/core/http/routes/localai.go
+++ b/core/http/routes/localai.go
@@ -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)
diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go
index 6fae5ccd6..2420da638 100644
--- a/core/http/routes/ui_api.go
+++ b/core/http/routes/ui_api.go
@@ -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.
diff --git a/core/schema/localai.go b/core/schema/localai.go
index a40f90c8d..7e5d5e314 100644
--- a/core/schema/localai.go
+++ b/core/schema/localai.go
@@ -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 {
diff --git a/core/services/monitoring/process_sampler.go b/core/services/monitoring/process_sampler.go
new file mode 100644
index 000000000..0959c75b5
--- /dev/null
+++ b/core/services/monitoring/process_sampler.go
@@ -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)
+ }
+ }
+}
diff --git a/core/services/monitoring/process_sampler_test.go b/core/services/monitoring/process_sampler_test.go
new file mode 100644
index 000000000..65f4377d0
--- /dev/null
+++ b/core/services/monitoring/process_sampler_test.go
@@ -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())
+ })
+})
diff --git a/docs/content/operations/overview.md b/docs/content/operations/overview.md
index ff92475db..f7c47122b 100644
--- a/docs/content/operations/overview.md
+++ b/docs/content/operations/overview.md
@@ -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
diff --git a/docs/content/reference/system-info.md b/docs/content/reference/system-info.md
index 60a337e48..b825e4e06 100644
--- a/docs/content/reference/system-info.md
+++ b/docs/content/reference/system-info.md
@@ -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