diff --git a/core/cli/agent_worker.go b/core/cli/agent_worker.go index ced179e50..11f515c51 100644 --- a/core/cli/agent_worker.go +++ b/core/cli/agent_worker.go @@ -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 diff --git a/core/http/endpoints/localai/nodes.go b/core/http/endpoints/localai/nodes.go index 331c7203b..2036f3d6e 100644 --- a/core/http/endpoints/localai/nodes.go +++ b/core/http/endpoints/localai/nodes.go @@ -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() diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index c6ea6e71d..5bafd472a 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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; } diff --git a/core/http/react-ui/src/components/nodes/NodeInspector.jsx b/core/http/react-ui/src/components/nodes/NodeInspector.jsx index c14cc9fcb..51860bf5e 100644 --- a/core/http/react-ui/src/components/nodes/NodeInspector.jsx +++ b/core/http/react-ui/src/components/nodes/NodeInspector.jsx @@ -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,

Node

{node.address || 'No address reported'} + {node.version || '—'} {timeAgo(node.last_heartbeat)}
{Object.keys(node.labels || {}).length ? Object.entries(node.labels).map(([key, value]) => {key}={value}) : No labels}
@@ -94,6 +110,26 @@ export default function NodeInspector({ node, open, onClose, onApprove, onDrain, {backendError ? {backendError} : backends === null ? 'Loading…' : `${backends.length} backend${backends.length === 1 ? '' : 's'}`} {node.in_flight_count ?? 0} +
+
Running models
+
+ {modelError ? {modelError} + : models === null ? Loading… + : models.length === 0 ? No models loaded + : } +
+