feat(distributed): per-node backend installation from the gallery

In distributed mode the Backends gallery used to fan every install out to
every worker — fine for auto-resolving (meta) backends like llama-cpp where
each node picks its own variant, but wrong for hardware-specific builds
like cpu-llama-cpp that would silently land on every GPU node.

Adds a node-targeted install path through the existing
POST /api/nodes/:id/backends/install plumbing, with two entry points:

- Backends gallery row gets a split-button in distributed mode. Auto-
  resolving keeps "Install on all nodes" as the primary; chevron menu
  opens the picker. Hardware-specific routes the primary directly to the
  picker — no fan-out path on the row.
- Nodes-page drawer gets a "+ Add backend" button that navigates to
  /app/backends?target=<node-id>; the gallery scopes itself to that node
  (banner, single per-row install button, Reinstall/Remove for already-
  installed). One gallery, two scopes — no second UI to maintain.

The picker (new NodeInstallPicker) shows a 3-state suitability column
(Compatible / Override / Installed), an auto-expanding variant override
disclosure that fires when selected nodes have no working GPU, parallel
per-node installs with inline status and Retry-failed-nodes, and a
mismatch confirm that names the consequence on the button itself.

A 409 fan-out guard on /api/backends/apply protects CLI/Terraform/script
users from the same footgun: hardware-specific installs in distributed
mode now return code "concrete_backend_requires_target" with a human-
readable error and a meta_alternative pointer.

The gallery list payload now surfaces capabilities, metaBackendFor and
per-row nodes (NodeBackendRef) so the picker and the new Nodes column
have everything they need without re-walking the gallery client-side.

GODEBUG=netdns=go is set on the compose services because the cgo DNS
resolver follows the container's nsswitch.conf to host systemd-resolved
(127.0.0.53), unreachable from inside the container; the pure-Go
resolver reads /etc/resolv.conf directly and uses Docker's embedded DNS.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-7[1m] [Edit] [Bash] [Read] [Write]
This commit is contained in:
Ettore Di Giacinto
2026-04-26 22:05:18 +00:00
parent 988430c850
commit 2da1a4d230
14 changed files with 1172 additions and 41 deletions

View File

@@ -98,7 +98,7 @@ func (mgs *BackendEndpointService) GetAllStatusEndpoint() echo.HandlerFunc {
// @Param request body GalleryBackend true "query params"
// @Success 200 {object} schema.BackendResponse "Response"
// @Router /backends/apply [post]
func (mgs *BackendEndpointService) ApplyBackendEndpoint() echo.HandlerFunc {
func (mgs *BackendEndpointService) ApplyBackendEndpoint(systemState *system.SystemState) echo.HandlerFunc {
return func(c echo.Context) error {
input := new(GalleryBackend)
// Get input data from the request body
@@ -106,6 +106,18 @@ func (mgs *BackendEndpointService) ApplyBackendEndpoint() echo.HandlerFunc {
return err
}
// In distributed mode, refuse to fan out a hardware-specific build to
// every node — a CPU build landing on a GPU cluster is almost always
// wrong, and the silent footgun is exactly what this guard exists for.
// Auto-resolving (meta) backends are fine because each node picks its
// own variant. Tooling can recover by hitting
// POST /api/nodes/{id}/backends/install per target node.
if mgs.backendApplier.BackendManager().IsDistributed() && input.ID != "" {
if guard := concreteFanOutGuard(c, mgs.galleries, systemState, input.ID); guard != nil {
return guard
}
}
uuid, err := uuid.NewUUID()
if err != nil {
return err
@@ -120,6 +132,66 @@ func (mgs *BackendEndpointService) ApplyBackendEndpoint() echo.HandlerFunc {
}
}
// concreteFanOutGuard returns a 409 response if the requested backend is a
// hardware-specific build (not auto-resolving / meta) and we are in
// distributed mode. It looks up the backend in the configured galleries; if
// the lookup itself fails (gallery unreachable, name not found), the guard
// stays out of the way and lets the install enqueue normally — a missing
// name will surface from the worker as a clearer error than the guard could
// produce here. The response body deliberately speaks human, with `code` and
// `meta_alternative` as the programmatic contract for tooling.
func concreteFanOutGuard(c echo.Context, galleries []config.Gallery, systemState *system.SystemState, backendID string) error {
// Use the unfiltered listing because in distributed mode the frontend's
// hardware is irrelevant — the install targets workers, not us — and the
// filtered list would hide variants that don't match the frontend host
// (e.g. a CUDA build on a CPU-only frontend), preventing the guard from
// firing for exactly the cases it's meant to protect against.
available, err := gallery.AvailableBackendsUnfiltered(galleries, systemState)
if err != nil {
return nil
}
requested := available.FindByName(backendID)
if requested == nil || requested.IsMeta() {
return nil
}
// Try to find an auto-resolving (meta) backend that has this concrete
// variant in its CapabilitiesMap, so we can suggest it as a one-shot
// alternative. Optional — empty string is fine if no parent exists.
metaAlternative := ""
for _, b := range available {
if !b.IsMeta() {
continue
}
for _, concrete := range b.CapabilitiesMap {
if concrete == backendID {
metaAlternative = b.Name
break
}
}
if metaAlternative != "" {
break
}
}
msg := fmt.Sprintf(
"Backend %q is a hardware-specific build and won't run correctly on every node in this cluster. In distributed mode, install it on specific nodes:\n\n POST /api/nodes/{node_id}/backends/install\n {\"backend\": %q}",
backendID, backendID,
)
if metaAlternative != "" {
msg += fmt.Sprintf(
"\n\nTo install across all nodes, use the auto-resolving backend %q — each node picks its own variant based on its hardware.",
metaAlternative,
)
}
return c.JSON(409, map[string]any{
"error": msg,
"code": "concrete_backend_requires_target",
"meta_alternative": metaAlternative,
})
}
// DeleteBackendEndpoint lets delete backends from a LocalAI instance
// @Summary delete backends from LocalAI.
// @Tags backends

View File

@@ -363,6 +363,9 @@ func ResumeNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
}
// InstallBackendOnNodeEndpoint triggers backend installation on a worker node via NATS.
// Backend can be either a gallery ID (resolved against BackendGalleries) or a
// direct URI install (URI + Name + optional Alias) — same shape as the
// standalone /api/backends/install-external path, just scoped to one node.
func InstallBackendOnNodeEndpoint(unloader nodes.NodeCommandSender) echo.HandlerFunc {
return func(c echo.Context) error {
if unloader == nil {
@@ -372,17 +375,24 @@ func InstallBackendOnNodeEndpoint(unloader nodes.NodeCommandSender) echo.Handler
var req struct {
Backend string `json:"backend"`
BackendGalleries string `json:"backend_galleries,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
Alias string `json:"alias,omitempty"`
}
if err := c.Bind(&req); err != nil || req.Backend == "" {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "backend name required"))
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "invalid request body"))
}
reply, err := unloader.InstallBackend(nodeID, req.Backend, "", req.BackendGalleries, "", "", "")
// Either a gallery backend name or a direct URI must be supplied.
if req.Backend == "" && req.URI == "" {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "backend name or uri required"))
}
reply, err := unloader.InstallBackend(nodeID, req.Backend, "", req.BackendGalleries, req.URI, req.Name, req.Alias)
if err != nil {
xlog.Error("Failed to install backend on node", "node", nodeID, "backend", req.Backend, "error", err)
xlog.Error("Failed to install backend on node", "node", nodeID, "backend", req.Backend, "uri", req.URI, "error", err)
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to install backend on node"))
}
if !reply.Success {
xlog.Error("Backend install failed on node", "node", nodeID, "backend", req.Backend, "error", reply.Error)
xlog.Error("Backend install failed on node", "node", nodeID, "backend", req.Backend, "uri", req.URI, "error", reply.Error)
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "backend installation failed"))
}
return c.JSON(http.StatusOK, map[string]string{"message": "backend installed"})

View File

@@ -0,0 +1,668 @@
import { useState, useMemo, useEffect, useRef } from 'react'
import Modal from './Modal'
import SearchableSelect from './SearchableSelect'
import { nodesApi } from '../utils/api'
// NodeInstallPicker is the single multi-node install surface used both from
// the Backends gallery split-button and from the "Install on more nodes" `+`
// affordance in the Nodes column. Submit fires N parallel per-node install
// calls; rows transition inline so the user sees per-node success/failure
// without leaving the modal.
//
// Props:
// open — controls visibility
// onClose — close handler (header X / Cancel / Esc / backdrop)
// onComplete — fired after at least one node install succeeded;
// gallery uses this to refetch and update the Nodes
// column without a manual reload
// backend — { name, isMeta, capabilities, metaBackendFor }
// nodes — BackendNode[] from /api/nodes
// installedNodeIds — Set/array of node IDs that already have this backend
// initialSelection — optional pre-selected node IDs (e.g. "missing nodes"
// when opened from the Nodes column `+` affordance)
const STATUS_LABELS = { healthy: 'Healthy', draining: 'Draining', unhealthy: 'Unhealthy', offline: 'Offline' }
function formatVRAM(bytes) {
if (!bytes || bytes === 0) return null
const gb = bytes / (1024 * 1024 * 1024)
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 * 1024)).toFixed(0)} MB`
}
function gpuVendorLabel(vendor) {
const labels = { nvidia: 'NVIDIA', amd: 'AMD', intel: 'Intel', vulkan: 'Vulkan' }
return labels[vendor] || null
}
// hardwareTargetOf parses the capability key that points to a concrete
// variant in the parent meta's CapabilitiesMap. e.g. cpu-llama-cpp comes
// from {"cpu": "cpu-llama-cpp"} → "cpu". Falls back to "" when the parent
// is unknown (the gallery list payload still gives us metaBackendFor).
function hardwareTargetOf(backend, allBackends) {
if (!backend || !backend.name || backend.isMeta) return ''
const parentName = backend.metaBackendFor
if (!parentName) return ''
const parent = (allBackends || []).find(b => b.name === parentName || b.id === parentName)
if (!parent || !parent.capabilities) return ''
for (const [cap, concreteName] of Object.entries(parent.capabilities)) {
if (concreteName === backend.name) return cap
}
return ''
}
// humanTargetLabel turns a capability key into a user-facing phrase used in
// the picker header note: "CPU build", "CUDA 12 build", etc. Keep it
// concrete and product-recognisable, not the raw token from the gallery.
function humanTargetLabel(target) {
if (!target) return 'hardware-specific build'
const t = target.toLowerCase()
if (t.startsWith('cpu') || t === 'default') return 'CPU build'
if (t.includes('cuda-13') || t.includes('cuda13')) return 'CUDA 13 build'
if (t.includes('cuda-12') || t.includes('cuda12')) return 'CUDA 12 build'
if (t.includes('cuda')) return 'NVIDIA CUDA build'
if (t.includes('l4t')) return 'NVIDIA Jetson (L4T) build'
if (t.includes('nvidia')) return 'NVIDIA build'
if (t.includes('rocm') || t.includes('amd')) return 'AMD ROCm build'
if (t.includes('metal')) return 'Apple Metal build'
if (t.includes('sycl') || t.includes('intel')) return 'Intel SYCL build'
if (t.includes('vulkan')) return 'Vulkan build'
if (t.includes('darwin-x86')) return 'macOS x86 build'
return 'hardware-specific build'
}
// suitabilityFor returns the picker's per-row suitability state for the
// requested backend. Already-installed wins over compatible/override so
// the user sees a single signal per row.
function suitabilityFor({ node, backend, hardwareTarget, alreadyInstalled }) {
if (alreadyInstalled) return 'installed'
// backend can be null on the first render before pickerBackend is set —
// this function is invoked from useMemo, which runs regardless of the
// outer open guard. Treat missing data as "compatible" so the placeholder
// render doesn't blow up; the picker won't actually paint anything until
// the early-return below the hooks fires.
if (!backend || backend.isMeta || !hardwareTarget) return 'compatible'
const vendor = (node.gpu_vendor || '').toLowerCase()
const t = hardwareTarget.toLowerCase()
if (t.startsWith('cpu') || t === 'default') {
// CPU builds always run; they're never marked Override (running CPU on a
// GPU node is the headline use case the user is choosing intentionally).
return 'compatible'
}
if (t.includes('nvidia') || t.includes('cuda') || t.includes('l4t')) {
return vendor === 'nvidia' ? 'compatible' : 'override'
}
if (t.includes('amd') || t.includes('rocm') || t.includes('hip')) {
return vendor === 'amd' ? 'compatible' : 'override'
}
if (t.includes('intel') || t.includes('sycl')) {
return vendor === 'intel' ? 'compatible' : 'override'
}
if (t.includes('metal') || t.includes('darwin')) {
// No vendor reporting for Metal; trust the user.
return 'compatible'
}
return 'compatible'
}
export default function NodeInstallPicker({
open, onClose, onComplete,
backend,
nodes = [],
allBackends = [],
installedNodeIds = [],
initialSelection,
addToast,
}) {
const [search, setSearch] = useState('')
const [showHealthy, setShowHealthy] = useState(true)
const [showDraining, setShowDraining] = useState(false)
const [selected, setSelected] = useState(() => new Set())
const [overrideVariant, setOverrideVariant] = useState('') // chosen concrete name
const [overrideExpanded, setOverrideExpanded] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [showMismatchConfirm, setShowMismatchConfirm] = useState(false)
// Per-node submission state: { [nodeId]: { status: 'pending'|'installing'|'done'|'error', error? , version? } }
const [perNode, setPerNode] = useState({})
const headerInputRef = useRef(null)
// Backend-derived metadata used throughout the picker.
const hardwareTarget = useMemo(() => hardwareTargetOf(backend, allBackends), [backend, allBackends])
const targetLabel = humanTargetLabel(hardwareTarget)
const concreteVariants = useMemo(() => {
if (!backend?.isMeta || !backend.capabilities) return []
return Object.entries(backend.capabilities).map(([cap, concrete]) => ({
value: concrete,
label: `${concrete} · ${cap}`,
}))
}, [backend])
// Pending nodes are surgically removed from the list — they can't accept
// installs until approved. Surface the count instead of dead-disabled rows.
const pendingCount = nodes.filter(n => n.status === 'pending').length
const backendNodes = nodes.filter(n =>
(!n.node_type || n.node_type === 'backend') && n.status !== 'pending'
)
const installedSet = useMemo(() => {
const s = new Set()
if (Array.isArray(installedNodeIds)) installedNodeIds.forEach(id => s.add(id))
else if (installedNodeIds && typeof installedNodeIds.has === 'function') {
installedNodeIds.forEach(id => s.add(id))
}
return s
}, [installedNodeIds])
const filteredNodes = useMemo(() => {
let list = backendNodes
if (!showHealthy) list = list.filter(n => n.status !== 'healthy')
if (!showDraining) list = list.filter(n => n.status !== 'draining')
if (search.trim()) {
const q = search.toLowerCase()
list = list.filter(n =>
(n.name || '').toLowerCase().includes(q) ||
Object.entries(n.labels || {}).some(([k, v]) => `${k}=${v}`.toLowerCase().includes(q))
)
}
return list
}, [backendNodes, showHealthy, showDraining, search])
// Pre-seed selection on open. Reset all transient state so reopening
// doesn't surface ghost progress from the prior submit.
useEffect(() => {
if (!open) return
const initial = new Set()
if (Array.isArray(initialSelection)) initialSelection.forEach(id => initial.add(id))
setSelected(initial)
setSearch('')
setOverrideVariant('')
setOverrideExpanded(false)
setPerNode({})
setSubmitting(false)
setShowMismatchConfirm(false)
}, [open, initialSelection])
// Auto-expand the variant override disclosure when at least one selected
// node lacks a working GPU. This is the headline use case the feature
// exists for; surfacing it instead of hiding behind a click.
useEffect(() => {
if (!backend?.isMeta) return
const someGPUMissing = Array.from(selected).some(id => {
const n = backendNodes.find(x => x.id === id)
return n && (!n.gpu_vendor || n.gpu_vendor === '' || n.gpu_vendor === 'unknown')
})
if (someGPUMissing && !overrideExpanded) setOverrideExpanded(true)
}, [selected, backend, backendNodes]) // eslint-disable-line react-hooks/exhaustive-deps
// The effective backend that gets installed on each node. For
// hardware-specific backends this is just backend.name. For meta backends
// with no override, the worker picks per-node — we pass backend.name and
// the worker resolves. With an override set, the picker installs that
// exact concrete variant on every selected node.
const effectiveBackendName = overrideVariant || backend?.name
const counts = useMemo(() => {
let already = 0, overrides = 0
selected.forEach(id => {
const n = backendNodes.find(x => x.id === id)
if (!n) return
if (installedSet.has(id)) { already++; return }
const eff = overrideVariant
? { name: overrideVariant, isMeta: false, metaBackendFor: backend?.name }
: backend
const target = overrideVariant ? hardwareTargetOf(eff, allBackends) : hardwareTarget
const s = suitabilityFor({ node: n, backend: eff, hardwareTarget: target, alreadyInstalled: false })
if (s === 'override') overrides++
})
return { already, overrides, selected: selected.size }
}, [selected, backendNodes, installedSet, overrideVariant, backend, hardwareTarget, allBackends])
const toggle = (nodeId) => {
setSelected(prev => {
const next = new Set(prev)
next.has(nodeId) ? next.delete(nodeId) : next.add(nodeId)
return next
})
}
const selectAllHealthy = () => {
setSelected(new Set(filteredNodes.filter(n => n.status === 'healthy').map(n => n.id)))
}
const selectCompatible = () => {
const eff = overrideVariant
? { name: overrideVariant, isMeta: false, metaBackendFor: backend?.name }
: backend
const target = overrideVariant ? hardwareTargetOf(eff, allBackends) : hardwareTarget
setSelected(new Set(
filteredNodes
.filter(n => suitabilityFor({ node: n, backend: eff, hardwareTarget: target, alreadyInstalled: false }) === 'compatible')
.map(n => n.id)
))
}
const clearSelection = () => setSelected(new Set())
const submit = async () => {
if (selected.size === 0 || submitting) return
if (counts.overrides > 0 && !showMismatchConfirm) {
setShowMismatchConfirm(true)
return
}
setShowMismatchConfirm(false)
setSubmitting(true)
const ids = Array.from(selected)
setPerNode(prev => {
const next = { ...prev }
ids.forEach(id => { next[id] = { status: 'installing' } })
return next
})
const results = await Promise.allSettled(ids.map(id =>
nodesApi.installBackend(id, effectiveBackendName)
.then(r => ({ id, ok: true, message: r?.message }))
.catch(err => ({ id, ok: false, error: err?.message || 'install failed' }))
))
let successCount = 0, failCount = 0
setPerNode(prev => {
const next = { ...prev }
for (const r of results) {
if (r.status !== 'fulfilled') continue
const v = r.value
if (v.ok) {
next[v.id] = { status: 'done' }
successCount++
} else {
next[v.id] = { status: 'error', error: v.error }
failCount++
}
}
return next
})
setSubmitting(false)
if (successCount > 0 && onComplete) onComplete()
if (failCount === 0) {
addToast?.(`Installed on ${successCount} node${successCount === 1 ? '' : 's'}`, 'success')
setTimeout(() => onClose?.(), 800)
} else if (successCount === 0) {
addToast?.(`Install failed on all ${failCount} node${failCount === 1 ? '' : 's'}`, 'error')
} else {
addToast?.(`Installed on ${successCount}, failed on ${failCount}`, 'warning')
}
}
const retryFailed = async () => {
const failedIds = Object.entries(perNode)
.filter(([, v]) => v.status === 'error')
.map(([id]) => id)
if (failedIds.length === 0) return
setSelected(new Set(failedIds))
// Replace state for failed rows so they show "installing" again, not stale errors.
setPerNode(prev => {
const next = { ...prev }
failedIds.forEach(id => { next[id] = { status: 'installing' } })
return next
})
setSubmitting(true)
const results = await Promise.allSettled(failedIds.map(id =>
nodesApi.installBackend(id, effectiveBackendName)
.then(r => ({ id, ok: true, message: r?.message }))
.catch(err => ({ id, ok: false, error: err?.message || 'install failed' }))
))
let successCount = 0, failCount = 0
setPerNode(prev => {
const next = { ...prev }
for (const r of results) {
if (r.status !== 'fulfilled') continue
const v = r.value
if (v.ok) { next[v.id] = { status: 'done' }; successCount++ }
else { next[v.id] = { status: 'error', error: v.error }; failCount++ }
}
return next
})
setSubmitting(false)
if (successCount > 0 && onComplete) onComplete()
if (failCount === 0) {
addToast?.(`Installed on ${successCount} node${successCount === 1 ? '' : 's'}`, 'success')
setTimeout(() => onClose?.(), 800)
}
}
const doneCount = Object.values(perNode).filter(v => v.status === 'done').length
const errorCount = Object.values(perNode).filter(v => v.status === 'error').length
const totalAttempted = Object.keys(perNode).length
if (!open || !backend) return null
const noNodes = backendNodes.length === 0
return (
<Modal onClose={onClose} maxWidth="780px">
<div style={{
padding: 'var(--spacing-md) var(--spacing-lg)',
borderBottom: '1px solid var(--color-border-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 'var(--spacing-sm)',
}}>
<h2 style={{ margin: 0, fontSize: '1rem', display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-cog" style={{ color: 'var(--color-primary)' }} />
Install <span style={{ fontFamily: 'var(--font-mono)' }}>{backend.name}</span>
{backend.isMeta ? (
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>Auto-resolving</span>
) : (
<span className="badge badge-warning" style={{ fontSize: '0.6875rem' }}>Hardware-specific</span>
)}
</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onClose}
aria-label="Close"
style={{ fontSize: '1.125rem', lineHeight: 1, padding: '4px 10px' }}
>×</button>
</div>
<div style={{ padding: 'var(--spacing-md) var(--spacing-lg)' }}>
{!backend.isMeta && (
<div className="card" style={{
marginBottom: 'var(--spacing-md)',
padding: 'var(--spacing-sm) var(--spacing-md)',
background: 'var(--color-warning-light)',
border: '1px solid var(--color-warning-border)',
borderRadius: 'var(--radius-md)',
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-sm)',
}}>
<i className="fas fa-microchip" style={{ color: 'var(--color-warning)' }} />
<span style={{ color: 'var(--color-warning)', fontSize: '0.8125rem' }}>
{targetLabel}. Install only on nodes where you want this build to run.
{hardwareTarget && ` Targets: ${humanTargetLabel(hardwareTarget).replace(' build', '')}.`}
</span>
</div>
)}
{noNodes ? (
<div className="empty-state" style={{ padding: 'var(--spacing-xl) 0' }}>
<div className="empty-state-icon"><i className="fas fa-server" /></div>
<h3 className="empty-state-title">No backend nodes available</h3>
<p className="empty-state-text">
Approve pending workers or register new ones.
{pendingCount > 0 && ` (${pendingCount} awaiting approval.)`}
</p>
<a className="btn btn-secondary btn-sm" href="/app/nodes">
<i className="fas fa-network-wired" /> Manage nodes
</a>
</div>
) : (
<>
{/* Filter row */}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'center', marginBottom: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
<div className="search-bar" style={{ flex: 1, minWidth: 180 }}>
<i className="fas fa-search search-icon" />
<input
ref={headerInputRef}
className="input"
placeholder="Filter nodes by name or label..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<button className="btn btn-secondary btn-sm" onClick={selectAllHealthy} type="button">
Select all healthy
</button>
<button className="btn btn-secondary btn-sm" onClick={selectCompatible} type="button">
Select compatible nodes
</button>
{selected.size > 0 && (
<button className="btn btn-ghost btn-sm" onClick={clearSelection} type="button">
Clear
</button>
)}
</div>
{/* Variant override (auto-resolving only) */}
{backend.isMeta && concreteVariants.length > 0 && (
<div style={{ marginBottom: 'var(--spacing-sm)' }}>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setOverrideExpanded(v => !v)}
aria-expanded={overrideExpanded}
style={{ padding: '4px 8px' }}
>
<i className={`fas fa-chevron-${overrideExpanded ? 'down' : 'right'}`} style={{ marginRight: 4, fontSize: '0.625rem' }} />
Override variant for selected nodes
</button>
{overrideExpanded && (
<div className="card" style={{ marginTop: 4, padding: 'var(--spacing-sm) var(--spacing-md)' }}>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginTop: 0, marginBottom: 'var(--spacing-xs)' }}>
By default each node picks its own variant. Override to install one specific variant on every selected node useful when GPU detection fails on a node and you want the CPU build there instead.
</p>
<SearchableSelect
value={overrideVariant}
onChange={setOverrideVariant}
options={concreteVariants}
placeholder="Per-node auto-resolve (default)"
allOption={{ value: '', label: 'Per-node auto-resolve (default)' }}
/>
</div>
)}
</div>
)}
{/* Node table */}
<div className="table-container" style={{ marginBottom: 'var(--spacing-sm)', maxHeight: '40vh', overflowY: 'auto' }}>
<table className="table" style={{ margin: 0 }}>
<thead>
<tr>
<th style={{ width: 28 }}>
<input
type="checkbox"
aria-label="Select all visible"
checked={filteredNodes.length > 0 && filteredNodes.every(n => selected.has(n.id))}
onChange={(e) => {
setSelected(prev => {
const next = new Set(prev)
if (e.target.checked) filteredNodes.forEach(n => next.add(n.id))
else filteredNodes.forEach(n => next.delete(n.id))
return next
})
}}
/>
</th>
<th>Node</th>
<th>Status</th>
<th>Hardware</th>
<th>Suitability</th>
</tr>
</thead>
<tbody>
{filteredNodes.map(node => {
const installed = installedSet.has(node.id)
const eff = overrideVariant
? { name: overrideVariant, isMeta: false, metaBackendFor: backend.name }
: backend
const target = overrideVariant ? hardwareTargetOf(eff, allBackends) : hardwareTarget
const suit = suitabilityFor({ node, backend: eff, hardwareTarget: target, alreadyInstalled: installed })
const isSel = selected.has(node.id)
const rowState = perNode[node.id]
const vendor = gpuVendorLabel(node.gpu_vendor)
const totalVRAM = formatVRAM(node.total_vram)
const totalRAM = formatVRAM(node.total_ram)
return (
<tr key={node.id}>
<td>
<input
type="checkbox"
aria-label={`Select ${node.name}`}
aria-disabled={rowState?.status === 'installing'}
checked={isSel}
onChange={() => toggle(node.id)}
/>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>{node.name}</span>
{node.labels && Object.keys(node.labels).length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{Object.entries(node.labels).slice(0, 3).map(([k, v]) => (
<span key={k} className="cell-mono" style={{
padding: '1px 5px', borderRadius: 'var(--radius-sm)', fontSize: '0.6875rem',
background: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border-subtle)',
}}>{k}={v}</span>
))}
{Object.keys(node.labels).length > 3 && (
<span className="cell-muted" style={{ fontSize: '0.6875rem' }}>
+{Object.keys(node.labels).length - 3}
</span>
)}
</div>
)}
</div>
</td>
<td>
<span style={{ fontSize: '0.8125rem' }}>
{STATUS_LABELS[node.status] || node.status}
</span>
</td>
<td style={{ fontSize: '0.8125rem', fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)' }}>
{totalVRAM ? (
<>{vendor && <span style={{ marginRight: 4 }}>{vendor}</span>}{totalVRAM}</>
) : totalRAM ? (
<span>CPU · {totalRAM}</span>
) : <span className="cell-muted"></span>}
</td>
<td>
{rowState?.status === 'installing' ? (
<span className="badge badge-info">
<i className="fas fa-spinner fa-spin" style={{ marginRight: 4 }} />Installing
</span>
) : rowState?.status === 'done' ? (
<span className="badge badge-success">
<i className="fas fa-check" style={{ marginRight: 4 }} />Installed
</span>
) : rowState?.status === 'error' ? (
<button
type="button"
className="badge badge-error"
title={rowState.error}
aria-describedby={`err-${node.id}`}
style={{ border: 'none', cursor: 'help' }}
>
<i className="fas fa-exclamation-triangle" style={{ marginRight: 4 }} />Failed
<span id={`err-${node.id}`} style={{ position: 'absolute', left: -9999 }}>{rowState.error}</span>
</button>
) : suit === 'installed' ? (
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
Installed
</span>
) : suit === 'override' ? (
<span className="badge badge-warning">
<i className="fas fa-exclamation-circle" style={{ marginRight: 4 }} />Override
</span>
) : (
<span className="badge badge-success" style={{ background: 'var(--color-success-light)', color: 'var(--color-success)' }}>
Compatible
</span>
)}
</td>
</tr>
)
})}
{filteredNodes.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: 'var(--spacing-md)', color: 'var(--color-text-muted)' }}>
No nodes match the current filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
{pendingCount > 0 && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 0, marginBottom: 'var(--spacing-sm)' }}>
+{pendingCount} awaiting approval <a href="/app/nodes" style={{ color: 'var(--color-primary)' }}>approve from Nodes</a>.
</p>
)}
{/* Mismatch confirm */}
{showMismatchConfirm && (
<div className="card" style={{
marginBottom: 'var(--spacing-sm)',
padding: 'var(--spacing-md)',
background: 'var(--color-warning-light)',
border: '1px solid var(--color-warning-border)',
borderRadius: 'var(--radius-md)',
}}>
<p style={{ marginTop: 0, marginBottom: 'var(--spacing-sm)', color: 'var(--color-warning)', fontSize: '0.875rem' }}>
Installing {targetLabel.toLowerCase()} on {counts.overrides} node{counts.overrides === 1 ? '' : 's'} that don't match. Those nodes will run inference on the chosen build, not their native GPU. Continue?
</p>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary btn-sm" type="button" onClick={() => setShowMismatchConfirm(false)}>
Cancel
</button>
<button className="btn btn-primary btn-sm" type="button" onClick={submit}
style={{ background: 'var(--color-warning)', borderColor: 'var(--color-warning)' }}>
Install on {targetLabel.replace(' build', '')}
</button>
</div>
</div>
)}
</>
)}
</div>
{!noNodes && (
<div style={{
padding: 'var(--spacing-md) var(--spacing-lg)',
borderTop: '1px solid var(--color-border-subtle)',
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-sm)',
flexWrap: 'wrap',
}}>
<div style={{ flex: 1, fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
{totalAttempted > 0 ? (
<>
{doneCount} of {totalAttempted} done
{errorCount > 0 && (
<> · <span className="badge badge-error" style={{ fontSize: '0.6875rem' }}>{errorCount} failed</span></>
)}
</>
) : (
<>
{counts.selected} {counts.selected === 1 ? 'node' : 'nodes'} selected
{counts.already > 0 && <> · {counts.already} already installed</>}
{counts.overrides > 0 && <> · {counts.overrides} override{counts.overrides === 1 ? '' : 's'}</>}
</>
)}
</div>
{errorCount > 0 && !submitting && (
<button className="btn btn-secondary btn-sm" type="button" onClick={retryFailed}>
<i className="fas fa-redo" /> Retry failed nodes
</button>
)}
<button className="btn btn-secondary btn-sm" type="button" onClick={onClose} disabled={submitting}>
{totalAttempted > 0 && doneCount > 0 ? 'Close' : 'Cancel'}
</button>
<button
className="btn btn-primary btn-sm"
type="button"
onClick={submit}
disabled={submitting || counts.selected === 0 || showMismatchConfirm}
>
{submitting ? (
<><i className="fas fa-spinner fa-spin" /> Installing…</>
) : (
<>Install on {counts.selected} {counts.selected === 1 ? 'node' : 'nodes'}</>
)}
</button>
</div>
)}
</Modal>
)
}

View File

@@ -0,0 +1,40 @@
import { useState, useEffect, useCallback } from 'react'
import { nodesApi } from '../utils/api'
// useDistributedMode probes /api/nodes to decide whether the running LocalAI
// is in distributed mode. The endpoint returns 503 when distributed mode is
// disabled — we treat any failure as standalone, mirroring the detection
// pattern in pages/Nodes.jsx so UI behaviour matches the Nodes page.
//
// Returns:
// enabled — true when the cluster API answered OK at least once
// nodes — the most recent /api/nodes response (array; possibly empty)
// loading — true until the first probe completes
// refetch — manual trigger; the picker calls this after install/delete
//
// Components that need a live nodes list (e.g. install picker) re-call
// refetch after operations complete. The hook does not poll on its own —
// the Nodes page handles its own 5s polling and the Backends gallery only
// needs a one-shot read on mount.
export function useDistributedMode() {
const [enabled, setEnabled] = useState(false)
const [nodes, setNodes] = useState([])
const [loading, setLoading] = useState(true)
const probe = useCallback(async () => {
try {
const data = await nodesApi.list()
setNodes(Array.isArray(data) ? data : [])
setEnabled(true)
} catch {
setEnabled(false)
setNodes([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { probe() }, [probe])
return { enabled, nodes, loading, refetch: probe }
}

View File

@@ -1,18 +1,24 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { backendsApi } from '../utils/api'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import { backendsApi, nodesApi } from '../utils/api'
import { useDebouncedCallback } from '../hooks/useDebounce'
import React from 'react'
import { useOperations } from '../hooks/useOperations'
import { useDistributedMode } from '../hooks/useDistributedMode'
import LoadingSpinner from '../components/LoadingSpinner'
import { renderMarkdown } from '../utils/markdown'
import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
import NodeDistributionChip from '../components/NodeDistributionChip'
import NodeInstallPicker from '../components/NodeInstallPicker'
import Popover from '../components/Popover'
export default function Backends() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { operations } = useOperations()
const { enabled: distributedEnabled, nodes: clusterNodes, refetch: refetchNodes } = useDistributedMode()
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('')
@@ -32,6 +38,31 @@ export default function Backends() {
const [showAllBackends, setShowAllBackends] = useState(false)
const [showDevelopment, setShowDevelopment] = useState(false)
const [preferDevLoaded, setPreferDevLoaded] = useState(false)
const [pickerBackend, setPickerBackend] = useState(null)
const [pickerInitialSelection, setPickerInitialSelection] = useState([])
const [splitMenuFor, setSplitMenuFor] = useState(null)
// Anchor ref for the currently-open split-button chevron. Only one row's
// menu can be open at a time, so a single ref is enough — re-attached
// whenever splitMenuFor changes to a different row index.
const splitMenuAnchorRef = useRef(null)
// Target-node mode: set when navigated from /app/nodes via "+ Add backend".
// The gallery page header banners the scope; rows collapse their split-button
// to a single Install-on-this-node action; manual install posts to the
// per-node endpoint.
const targetNodeId = searchParams.get('target') || ''
const targetNode = targetNodeId
? clusterNodes.find(n => n.id === targetNodeId) || null
: null
const clearTarget = useCallback(() => {
const next = new URLSearchParams(searchParams)
next.delete('target')
setSearchParams(next, { replace: true })
}, [searchParams, setSearchParams])
// The Popover component handles outside-click + Escape + focus return,
// so we don't reimplement it here.
const fetchBackends = useCallback(async () => {
try {
@@ -127,10 +158,54 @@ export default function Backends() {
try {
await backendsApi.install(id)
} catch (err) {
// Distributed-mode 409 guard: surface the human message and steer the
// user to the picker rather than failing silently. The error body has
// a `code` field of "concrete_backend_requires_target".
const isConcreteGuard = err?.payload?.code === 'concrete_backend_requires_target'
|| (err?.message || '').includes('hardware-specific build')
if (isConcreteGuard && distributedEnabled) {
const b = allBackends.find(x => x.id === id || x.name === id)
if (b) {
openPicker(b)
return
}
}
addToast(`Install failed: ${err.message}`, 'error')
}
}
// Install a single gallery backend on a specific node, used in target-node
// mode (the URL has ?target=<node-id> set from the Nodes page entry point).
const handleInstallOnTarget = async (id) => {
if (!targetNode) return
try {
await nodesApi.installBackend(targetNode.id, id)
addToast(`Installing ${id} on ${targetNode.name}`, 'info')
// Per-node install is request-reply, not part of the global jobs feed —
// refetch to reflect the new Nodes column state.
setTimeout(() => { fetchBackends(); refetchNodes() }, 600)
} catch (err) {
addToast(`Install failed on ${targetNode.name}: ${err.message}`, 'error')
}
}
const openPicker = (b, initialSelection = []) => {
setPickerBackend(b)
setPickerInitialSelection(initialSelection)
setSplitMenuFor(null)
}
// Returns the IDs of nodes that don't yet have this backend installed.
// Used by the Nodes column "+" affordance to pre-select missing nodes.
const missingNodesFor = (b) => {
const installed = new Set((b?.nodes || []).map(n => n.node_id ?? n.NodeID))
return clusterNodes
.filter(n => (!n.node_type || n.node_type === 'backend')
&& n.status === 'healthy'
&& !installed.has(n.id))
.map(n => n.id)
}
const handleDelete = async (id) => {
setConfirmDialog({
title: 'Delete Backend',
@@ -179,10 +254,26 @@ export default function Backends() {
e.preventDefault()
if (!manualUri.trim()) { addToast('Please enter a URI', 'warning'); return }
try {
const body = { uri: manualUri.trim() }
if (manualName.trim()) body.name = manualName.trim()
if (manualAlias.trim()) body.alias = manualAlias.trim()
await backendsApi.installExternal(body)
if (targetNode) {
// Target-node mode: route the manual install to the per-node endpoint
// so the backend lands only on this worker, not the whole cluster.
await nodesApi.installBackend(
targetNode.id,
manualName.trim() || '',
{
uri: manualUri.trim(),
name: manualName.trim() || undefined,
alias: manualAlias.trim() || undefined,
},
)
addToast(`Installing on ${targetNode.name}`, 'info')
setTimeout(() => { fetchBackends(); refetchNodes() }, 600)
} else {
const body = { uri: manualUri.trim() }
if (manualName.trim()) body.name = manualName.trim()
if (manualAlias.trim()) body.alias = manualAlias.trim()
await backendsApi.installExternal(body)
}
setManualUri('')
setManualName('')
setManualAlias('')
@@ -225,6 +316,31 @@ export default function Backends() {
return (
<div className="page">
{/* Target-node banner: when this gallery is scoped to one node via
?target=<id> (entered from /app/nodes), show the scope clearly and
give a fast way to clear it. Visually a primary-tinted strip so the
user knows they're in a special mode without it feeling alarming. */}
{targetNode && (
<div className="card" style={{
marginBottom: 'var(--spacing-md)',
padding: 'var(--spacing-sm) var(--spacing-md)',
background: 'var(--color-primary-light)',
border: '1px solid var(--color-primary-border)',
borderRadius: 'var(--radius-md)',
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
flexWrap: 'wrap',
}}>
<i className="fas fa-bullseye" style={{ color: 'var(--color-primary)' }} />
<span style={{ color: 'var(--color-primary)', fontWeight: 500, fontSize: 'var(--text-sm)' }}>
Installing only on <span style={{ fontFamily: 'var(--font-mono)' }}>{targetNode.name}</span>
</span>
<span style={{ flex: 1 }} />
<button className="btn btn-ghost btn-sm" type="button" onClick={clearTarget}>
<i className="fas fa-times" /> Clear
</button>
</div>
)}
{/* Header */}
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
@@ -377,6 +493,7 @@ export default function Backends() {
<SortHeader col="repository">Repository</SortHeader>
<SortHeader col="license">License</SortHeader>
<SortHeader col="status">Status</SortHeader>
{distributedEnabled && !targetNode && <th>Nodes</th>}
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
@@ -446,7 +563,10 @@ export default function Backends() {
) : '-'}
</td>
{/* Status */}
{/* Status — in distributed mode the Nodes column is the
installed signal, so we drop the global "Installed"
badge here and only keep operation-progress / update
signals to avoid stacking 6 badges in one cell. */}
<td>
{isProcessing ? (
<div className="inline-install">
@@ -464,9 +584,16 @@ export default function Backends() {
</div>
) : b.installed ? (
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="badge badge-success">
<i className="fas fa-check" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Installed
</span>
{!distributedEnabled && (
<span className="badge badge-success">
<i className="fas fa-check" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Installed
</span>
)}
{b.version && (
<span className="badge" style={{ fontSize: '0.625rem', background: 'var(--color-bg-tertiary)', color: 'var(--color-text-secondary)' }}>
v{b.version}
</span>
)}
{upgrades[b.name] && (
<span className="badge" style={{ fontSize: '0.625rem', background: 'var(--color-warning-light)', color: 'var(--color-warning)' }}>
<i className="fas fa-arrow-up" style={{ fontSize: '0.5rem', marginRight: 2 }} />
@@ -481,10 +608,67 @@ export default function Backends() {
)}
</td>
{/* Nodes column (distributed mode only, hidden in target
mode since it's redundant with the banner). The chip
is read-only inspection; the adjacent + button is the
write affordance — keeping them visually separate so
users don't accidentally trigger the picker by clicking
to read distribution. */}
{distributedEnabled && !targetNode && (
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<NodeDistributionChip nodes={b.nodes || []} />
{(() => {
const missing = missingNodesFor(b)
if (missing.length === 0 || isProcessing) return null
return (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={(e) => { e.stopPropagation(); openPicker(b, missing) }}
title={`Install on ${missing.length} more node${missing.length === 1 ? '' : 's'}`}
aria-label="Install on more nodes"
style={{ padding: '2px 6px' }}
>
<i className="fas fa-plus" style={{ fontSize: '0.6875rem' }} />
</button>
)
})()}
</div>
</td>
)}
{/* Actions */}
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
{b.installed ? (
{targetNode ? (
// Target-node mode: collapse to a single per-node
// action. The split-button is overkill when scope is
// already pinned by the URL.
(b.nodes || []).some(n => (n.node_id ?? n.NodeID) === targetNode.id) ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => handleInstallOnTarget(b.name || b.id)} disabled={isProcessing}
title={`Reinstall on ${targetNode.name}`}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-rotate'}`} /> Reinstall
</button>
<button className="btn btn-danger btn-sm" onClick={async () => {
try {
await nodesApi.deleteBackend(targetNode.id, b.name || b.id)
addToast(`Removed ${b.name} from ${targetNode.name}`, 'success')
setTimeout(() => { fetchBackends(); refetchNodes() }, 600)
} catch (err) {
addToast(`Remove failed: ${err.message}`, 'error')
}
}} title={`Remove from ${targetNode.name}`} disabled={isProcessing}>
<i className="fas fa-trash" />
</button>
</>
) : (
<button className="btn btn-primary btn-sm" onClick={() => handleInstallOnTarget(b.name || b.id)} disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-download'}`} /> Install on {targetNode.name}
</button>
)
) : b.installed ? (
<>
{upgrades[b.name] ? (
<button className="btn btn-primary btn-sm" onClick={() => handleUpgrade(b.name || b.id)} title={`Upgrade to ${upgrades[b.name]?.available_version ? 'v' + upgrades[b.name].available_version : 'latest'}`} disabled={isProcessing}>
@@ -499,6 +683,41 @@ export default function Backends() {
<i className="fas fa-trash" />
</button>
</>
) : distributedEnabled ? (
// Split-button. Auto-resolving (meta) keeps fan-out
// as the primary; hardware-specific routes the
// primary directly to the picker — fan-out for a
// CPU build is the silent footgun this guard exists
// to prevent. Both share a chevron menu for the
// alternate path.
b.isMeta ? (
<div style={{ display: 'inline-flex' }}>
<button className="btn btn-primary btn-sm" onClick={() => handleInstall(b.name || b.id)} disabled={isProcessing} title="Install on all nodes" style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-download'}`} /> Install on all
</button>
<button
ref={splitMenuFor === idx ? splitMenuAnchorRef : undefined}
className="btn btn-primary btn-sm"
onClick={() => setSplitMenuFor(splitMenuFor === idx ? null : idx)}
aria-haspopup="menu"
aria-expanded={splitMenuFor === idx}
aria-label="More install options"
disabled={isProcessing}
style={{ padding: '0 8px', borderLeft: '1px solid rgba(0,0,0,0.15)', borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
>
<i className={`fas fa-chevron-${splitMenuFor === idx ? 'up' : 'down'}`} style={{ fontSize: '0.6875rem' }} />
</button>
</div>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => openPicker(b)}
disabled={isProcessing}
title="Choose nodes to install on"
>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-server'}`} /> Choose nodes
</button>
)
) : (
<button className="btn btn-primary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Install" disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-download'}`} />
@@ -510,7 +729,7 @@ export default function Backends() {
{/* Expanded detail row */}
{isExpanded && (
<tr>
<td colSpan="8" style={{ padding: 0 }}>
<td colSpan={distributedEnabled && !targetNode ? 9 : 8} style={{ padding: 0 }}>
<BackendDetail backend={b} />
</td>
</tr>
@@ -550,6 +769,43 @@ export default function Backends() {
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
{/* Single popover instance for the split-button menu, anchored to
whichever row's chevron is currently active. Reusing the existing
Popover gives us .card surface + outside-click + Escape + focus
return for free. */}
<Popover
anchor={splitMenuAnchorRef}
open={splitMenuFor !== null}
onClose={() => setSplitMenuFor(null)}
ariaLabel="Install options"
>
<div className="action-menu">
<button
type="button"
className="action-menu__item"
onClick={() => {
const b = backends[splitMenuFor]
if (b) openPicker(b)
}}
>
<i className="fas fa-server action-menu__icon" />
Install on specific nodes
</button>
</div>
</Popover>
<NodeInstallPicker
open={!!pickerBackend}
onClose={() => { setPickerBackend(null); setPickerInitialSelection([]) }}
onComplete={() => { fetchBackends(); refetchNodes() }}
backend={pickerBackend}
nodes={clusterNodes}
allBackends={allBackends}
installedNodeIds={(pickerBackend?.nodes || []).map(n => n.node_id ?? n.NodeID)}
initialSelection={pickerInitialSelection}
addToast={addToast}
/>
</div>
)
}

View File

@@ -845,10 +845,30 @@ export default function Nodes() {
</table>
)}
<h4 style={{ fontSize: '0.8125rem', fontWeight: 600, marginTop: 'var(--spacing-md)', marginBottom: 'var(--spacing-sm)', color: 'var(--color-text-secondary)' }}>
<i className="fas fa-cogs" style={{ marginRight: 6 }} />
Installed Backends
</h4>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginTop: 'var(--spacing-md)', marginBottom: 'var(--spacing-sm)',
}}>
<h4 style={{ fontSize: '0.8125rem', fontWeight: 600, margin: 0, color: 'var(--color-text-secondary)' }}>
<i className="fas fa-cogs" style={{ marginRight: 6 }} />
Installed Backends
</h4>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={(e) => {
e.stopPropagation()
// Hand off to the gallery in target-node mode.
// The Backends page reads ?target=<id> and
// scopes its install action to this node —
// one gallery, two scopes, no duplicate UI.
navigate(`/app/backends?target=${encodeURIComponent(node.id)}`)
}}
title={`Install a backend on ${node.name}`}
>
<i className="fas fa-plus" /> Add backend
</button>
</div>
{!backends ? (
<LoadingSpinner size="sm" />
) : backends.length === 0 ? (

View File

@@ -463,7 +463,17 @@ export const nodesApi = {
approve: (id) => postJSON(API_CONFIG.endpoints.nodeApprove(id), {}),
getModels: (id) => fetchJSON(API_CONFIG.endpoints.nodeModels(id)),
getBackends: (id) => fetchJSON(API_CONFIG.endpoints.nodeBackends(id)),
installBackend: (id, backend) => postJSON(API_CONFIG.endpoints.nodeBackendsInstall(id), { backend }),
// installBackend installs a gallery backend on a single node. opts can
// override the gallery path and supply a direct URI (OCI image / URL / file
// path) plus an optional name+alias, mirroring the standalone /backends/
// install-external surface but scoped to one node.
installBackend: (id, backend, opts = {}) => postJSON(API_CONFIG.endpoints.nodeBackendsInstall(id), {
backend,
...(opts.uri ? { uri: opts.uri } : {}),
...(opts.name ? { name: opts.name } : {}),
...(opts.alias ? { alias: opts.alias } : {}),
...(opts.backend_galleries ? { backend_galleries: opts.backend_galleries } : {}),
}),
deleteBackend: (id, backend) => postJSON(API_CONFIG.endpoints.nodeBackendsDelete(id), { backend }),
getBackendLogs: (id) => fetchJSON(API_CONFIG.endpoints.nodeBackendLogs(id)),
getBackendLogLines: (id, modelId) => fetchJSON(API_CONFIG.endpoints.nodeBackendLogsModel(id, modelId)),

View File

@@ -61,7 +61,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
appConfig.SystemState,
galleryService,
app.UpgradeChecker())
router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint(), adminMiddleware)
router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint(appConfig.SystemState), adminMiddleware)
router.POST("/backends/delete/:name", backendGalleryEndpointService.DeleteBackendEndpoint(), adminMiddleware)
router.GET("/backends", backendGalleryEndpointService.ListBackendsEndpoint(), adminMiddleware)
router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState), adminMiddleware)

View File

@@ -40,6 +40,25 @@ const (
)
// getDirectorySize calculates the total size of files in a directory
// metaParentOf returns the name of the auto-resolving (meta) backend that
// declares `name` as one of its hardware-specific variants in its
// CapabilitiesMap, or "" if there is no such parent. The install picker uses
// this to render hints like "CPU build of llama-cpp" without re-walking the
// whole gallery on the client side.
func metaParentOf(name string, backends gallery.GalleryElements[*gallery.GalleryBackend]) string {
for _, b := range backends {
if !b.IsMeta() {
continue
}
for _, concreteName := range b.CapabilitiesMap {
if concreteName == name {
return b.Name
}
}
}
return ""
}
func getDirectorySize(path string) (int64, error) {
var totalSize int64
entries, err := os.ReadDir(path)
@@ -998,23 +1017,37 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
}
// Per-node distribution + parent meta lookup for the install picker.
// `nodes` populates the Nodes column on the gallery; `metaBackendFor`
// lets the picker name the parent (e.g. "CPU build of llama-cpp")
// without re-walking the whole gallery on the client.
var perNode []gallery.NodeBackendRef
if installedBackends != nil {
if sb, ok := installedBackends.Get(b.Name); ok {
perNode = sb.Nodes
}
}
backendsJSON = append(backendsJSON, map[string]any{
"id": backendID,
"name": b.Name,
"description": b.Description,
"icon": b.Icon,
"license": b.License,
"urls": b.URLs,
"tags": b.Tags,
"gallery": b.Gallery.Name,
"installed": b.Installed,
"version": b.Version,
"processing": currentlyProcessing,
"jobID": jobID,
"isDeletion": isDeletionOp,
"isMeta": b.IsMeta(),
"isAlias": aliasedByMeta[b.Name],
"isDevelopment": b.IsDevelopment(devSuffix),
"id": backendID,
"name": b.Name,
"description": b.Description,
"icon": b.Icon,
"license": b.License,
"urls": b.URLs,
"tags": b.Tags,
"gallery": b.Gallery.Name,
"installed": b.Installed,
"version": b.Version,
"processing": currentlyProcessing,
"jobID": jobID,
"isDeletion": isDeletionOp,
"isMeta": b.IsMeta(),
"isAlias": aliasedByMeta[b.Name],
"isDevelopment": b.IsDevelopment(devSuffix),
"capabilities": b.CapabilitiesMap,
"metaBackendFor": metaParentOf(b.Name, backends),
"nodes": perNode,
})
}

View File

@@ -22,4 +22,9 @@ type BackendManager interface {
ListBackends() (gallery.SystemBackends, error)
UpgradeBackend(ctx context.Context, name string, progressCb ProgressCallback) error
CheckUpgrades(ctx context.Context) (map[string]gallery.UpgradeInfo, error)
// IsDistributed reports whether installs fan out across worker nodes.
// The HTTP layer uses this to refuse hardware-specific (non-meta) installs
// on /api/backends/apply in distributed mode — a CPU build silently
// landing on every GPU node is the footgun this guards against.
IsDistributed() bool
}

View File

@@ -108,3 +108,5 @@ func (b *LocalBackendManager) InstallBackend(ctx context.Context, op *Management
return gallery.InstallBackendFromGallery(ctx, b.backendGalleries, b.systemState,
b.modelLoader, op.GalleryElementName, progressCb, true)
}
func (b *LocalBackendManager) IsDistributed() bool { return false }

View File

@@ -364,6 +364,11 @@ func (d *DistributedBackendManager) UpgradeBackend(ctx context.Context, name str
return result.Err()
}
// IsDistributed reports that installs from this manager fan out across the
// cluster. The HTTP layer reads this to gate hardware-specific installs on
// /api/backends/apply (which would otherwise silently land on every node).
func (d *DistributedBackendManager) IsDistributed() bool { return true }
// CheckUpgrades checks for available backend upgrades across the cluster.
//
// The previous implementation delegated to d.local, which called

View File

@@ -108,6 +108,7 @@ func (stubLocalBackendManager) UpgradeBackend(_ context.Context, _ string, _ gal
func (stubLocalBackendManager) CheckUpgrades(_ context.Context) (map[string]gallery.UpgradeInfo, error) {
return nil, nil
}
func (stubLocalBackendManager) IsDistributed() bool { return false }
var _ = Describe("DistributedBackendManager", func() {
var (

View File

@@ -60,6 +60,13 @@ services:
# Auth (required for distributed mode — must use PostgreSQL)
LOCALAI_AUTH: "true"
LOCALAI_AUTH_DATABASE_URL: "postgresql://localai:localai@postgres:5432/localai?sslmode=disable"
# Force pure-Go DNS resolver. The default cgo resolver follows the
# container's nsswitch.conf and ends up forwarding to host
# systemd-resolved (127.0.0.53), which isn't reachable from inside
# the container — failing every postgres/nats hostname lookup at
# boot. The pure-Go path reads /etc/resolv.conf directly and uses
# Docker's embedded DNS at 127.0.0.11.
GODEBUG: "netdns=go"
# Paths
MODELS_PATH: /models
volumes:
@@ -99,6 +106,7 @@ services:
LOCALAI_REGISTRATION_TOKEN: "changeme" # Must match frontend token
LOCALAI_HEARTBEAT_INTERVAL: "10s"
LOCALAI_NATS_URL: "nats://nats:4222"
GODEBUG: "netdns=go" # See note in localai service
MODELS_PATH: /models
volumes:
- worker_1_models:/models
@@ -184,6 +192,7 @@ services:
LOCALAI_REGISTER_TO: "http://localai:8080"
LOCALAI_NODE_NAME: "agent-worker-1"
LOCALAI_REGISTRATION_TOKEN: "changeme" # Must match frontend token
GODEBUG: "netdns=go" # See note in localai service
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on: