feat(distributed): report worker version and show models in node inspector (#12328)

* ci: bump Hugo from 0.146.3 to 0.166.0

The hugo-theme-relearn submodule was bumped to 9.1.x in #12096,
which requires Hugo >= 0.165.0. The pinned 0.146.3 broke the docs
site build with a template error in alias.html that could not
evaluate the Locale field on langs.Language.

Bump HUGO_VERSION to 0.166.0 (latest stable) to satisfy the
theme minimum and resolve the alias.html template error.

Assisted-by: nib:claude-sonnet-4.5 [bash] [read] [edit]

* feat(distributed): report worker version and show models in node inspector

Workers now send their LocalAI build version and git commit at
registration. The controller stores them on BackendNode and exposes
them through the existing node list/detail API responses.

The node inspector side pane now fetches and renders the list of
loaded models (name, state, in-flight) instead of showing only a
count, matching what the node detail page already displays.

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-09-28 13:13:31 +02:00
1 parent 102fbe0c78
commit 590512d9eb
7 files changed
+66 -1

No files matched your search

+3
View File
@@ -19,6 +19,7 @@ import (
"github.com/mudler/LocalAI/core/services/jobs"
mcpRemote "github.com/mudler/LocalAI/core/services/mcp"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/sanitize"
"github.com/mudler/cogito"
"github.com/mudler/cogito/clients"
@@ -94,6 +95,8 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
registrationBody := map[string]any{
"name": nodeName,
"node_type": "agent",
"version": internal.Version,
"commit": internal.Commit,
}
if cmd.RegistrationToken != "" {
registrationBody["token"] = cmd.RegistrationToken
+7
View File
@@ -109,6 +109,11 @@ type RegisterNodeRequest struct {
// VRAMBudget is the worker's operator-set VRAM cap ("80%" or "12GB"). The
// registry resolves and enforces it against the raw reported VRAM.
VRAMBudget string `json:"vram_budget,omitempty"`
// Version is the LocalAI build version reported by the worker at
// registration. Empty for workers registered before this field existed.
Version string `json:"version,omitempty"`
// Commit is the git commit hash the worker binary was built from.
Commit string `json:"commit,omitempty"`
}
// RegisterNodeEndpoint registers a new backend node.
@@ -195,6 +200,8 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
Capability: req.Capability,
MaxReplicasPerModel: maxReplicasPerModel,
VRAMBudget: req.VRAMBudget,
Version: req.Version,
Commit: req.Commit,
}
ctx := c.Request().Context()
+7
View File
@@ -9965,6 +9965,13 @@ button.collapsible-header:focus-visible {
.node-inspector__actions .btn { justify-content: center; min-width: 0; }
.node-inspector__back { align-items: center; background: transparent; border: 0; color: var(--color-primary); cursor: pointer; display: flex; font: inherit; font-size: var(--text-xs); gap: 6px; max-width: 285px; overflow: hidden; padding: 3px 0; text-overflow: ellipsis; white-space: nowrap; }
.node-inspector__back:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--color-primary); outline-offset: 3px; }
.node-inspector__models { margin: 10px 0 0; }
.node-inspector__models > dd { margin: 0; }
.node-inspector__model-list { display: grid; gap: 4px; list-style: none; margin: 6px 0 0; padding: 0; }
.node-inspector__model-row { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; font-size: var(--text-xs); }
.node-inspector__model-row .cell-mono { font-family: var(--font-mono); font-size: .625rem; overflow-wrap: anywhere; }
.node-inspector__model-row .state-pill { border-radius: var(--radius-full); font-size: .5625rem; font-weight: 600; padding: 1px 7px; text-transform: capitalize; }
.node-inspector__model-row .text-muted { font-size: .5625rem; }
.model-inspector__backends { margin-top: 10px; }
.model-inspector__nodes { display: grid; gap: 9px; }
.model-inspector__node { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: 10px; }
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import StatusPill from './StatusPill'
import { formatBytes, formatCapacity, timeAgo } from './nodeStatus'
import { formatBytes, formatCapacity, timeAgo, modelStateConfig } from './nodeStatus'
import { nodesApi } from '../../utils/api'
import { capacityReading, nodeLifecycleAction } from '../../utils/nodeFleet'
import useInspectorDrawer from './useInspectorDrawer'
@@ -24,6 +24,8 @@ function ResourceBar({ label, total, available, tone }) {
export default function NodeInspector({ node, open, onClose, onApprove, onDrain, onResume, onBack, backLabel }) {
const [backends, setBackends] = useState(null)
const [backendError, setBackendError] = useState('')
const [models, setModels] = useState(null)
const [modelError, setModelError] = useState('')
const nodeId = node?.id
const backRef = useRef(null)
const closeRef = useRef(null)
@@ -48,6 +50,19 @@ export default function NodeInspector({ node, open, onClose, onApprove, onDrain,
return () => { current = false }
}, [open, nodeId])
useEffect(() => {
if (!open || !nodeId) return undefined
let current = true
setModels(null)
setModelError('')
nodesApi.getModels(nodeId).then(data => {
if (current) setModels(Array.isArray(data) ? data : [])
}).catch(error => {
if (current) setModelError(error.message || 'Unable to load models')
})
return () => { current = false }
}, [open, nodeId])
if (!open || !node) return null
const cpuKnown = node.cpu_logical_cores > 0 && Number.isFinite(node.cpu_usage_percent) && Number.isFinite(node.cpu_load_1)
const disk = capacityReading(node.total_disk, node.available_disk)
@@ -74,6 +89,7 @@ export default function NodeInspector({ node, open, onClose, onApprove, onDrain,
<h3>Node</h3>
<dl className="node-inspector__metrics">
<InspectorMetric label="Address"><span className="node-inspector__address">{node.address || 'No address reported'}</span></InspectorMetric>
<InspectorMetric label="Version">{node.version || '—'}</InspectorMetric>
<InspectorMetric label="Heartbeat">{timeAgo(node.last_heartbeat)}</InspectorMetric>
</dl>
<div className="node-inspector__labels" aria-label="Node labels">{Object.keys(node.labels || {}).length ? Object.entries(node.labels).map(([key, value]) => <span key={key}>{key}={value}</span>) : <span className="text-muted">No labels</span>}</div>
@@ -94,6 +110,26 @@ export default function NodeInspector({ node, open, onClose, onApprove, onDrain,
<InspectorMetric label="Backends">{backendError ? <span className="text-error">{backendError}</span> : backends === null ? 'Loading…' : `${backends.length} backend${backends.length === 1 ? '' : 's'}`}</InspectorMetric>
<InspectorMetric label="In-flight work">{node.in_flight_count ?? 0}</InspectorMetric>
</dl>
<div className="node-inspector__models">
<dt className="drawer-eyebrow">Running models</dt>
<dd>
{modelError ? <span className="text-error">{modelError}</span>
: models === null ? <span className="text-muted">Loading…</span>
: models.length === 0 ? <span className="text-muted">No models loaded</span>
: <ul className="node-inspector__model-list">
{models.map(model => {
const stCfg = modelStateConfig[model.state] || modelStateConfig.idle
return (
<li key={model.id || `${model.model_name}#${model.replica_index}`} className="node-inspector__model-row">
<span className="cell-mono">{model.model_name}</span>
<span className="state-pill" style={{ background: stCfg.bg, color: stCfg.color, border: `1px solid ${stCfg.border}` }}>{model.state}</span>
<span className="text-muted">{model.in_flight ?? 0} in flight</span>
</li>
)
})}
</ul>}
</dd>
</div>
</section>
</div>
<footer className="node-inspector__actions">
@@ -138,6 +138,10 @@ export default function NodeDetail() {
<div className="drawer-eyebrow">In-flight</div>
<span className="cell-mono">{node.in_flight_count || 0}</span>
</div>
<div>
<div className="drawer-eyebrow">Version</div>
<span className="cell-mono">{node.version || '—'}</span>
</div>
<div>
<div className="drawer-eyebrow">Heartbeat</div>
<span>{timeAgo(node.last_heartbeat)}</span>
+5
View File
@@ -89,6 +89,11 @@ type BackendNode struct {
// worker's re-registration value does not clobber it (mirrors
// MaxReplicasPerModelManuallySet).
VRAMBudgetManuallySet bool `gorm:"column:vram_budget_manually_set;default:false" json:"vram_budget_manually_set"`
// Version is the LocalAI build version reported by the worker at
// registration. Empty for workers registered before this field existed.
Version string `gorm:"column:version;size:64" json:"version,omitempty"`
// Commit is the git commit hash the worker binary was built from.
Commit string `gorm:"column:commit;size:64" json:"commit,omitempty"`
APIKeyID string `gorm:"size:36" json:"-"` // auto-provisioned API key ID (for cleanup)
AuthUserID string `gorm:"size:36" json:"-"` // auto-provisioned user ID (for cleanup)
LastHeartbeat time.Time `gorm:"column:last_heartbeat" json:"last_heartbeat"`
+3
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/xsysinfo"
"github.com/mudler/xlog"
@@ -173,6 +174,8 @@ func (cfg *Config) registrationBody() map[string]any {
"gpu_compute_capability": gpuComputeCap,
"capability": capability,
"max_replicas_per_model": maxReplicas,
"version": internal.Version,
"commit": internal.Commit,
}
// Report free space on the filesystem that backs the MODELS directory.