fix(nodes): show a node's existing labels on the detail view (#10529)

fix(nodes): return labels in single-node GET so the detail view shows them

The node detail view (/app/nodes/:id) reads `node.labels` to render a
node's existing labels, but the single-node GET endpoint returned a bare
BackendNode whose Labels live in a separate table - so the list was always
empty and operators could only add labels, never see what was already set
(#10527). The same response also lacked in_flight_count and model_count.

Add NodeRegistry.GetWithExtras, mirroring the existing List vs ListWithExtras
split: bare Get stays cheap for the routing hot paths and existence checks,
while the detail endpoint uses the enriched variant to attach the labels map
and live counts. No frontend change is needed - the UI already renders
existing labels once the data is present.

Closes #10527


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
LocalAI [bot]
2026-06-26 23:06:42 +02:00
committed by GitHub
parent c4fa256cdf
commit 56600eec3e
3 changed files with 79 additions and 1 deletions

View File

@@ -60,7 +60,10 @@ func GetNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
id := c.Param("id")
node, err := registry.Get(ctx, id)
// GetWithExtras (not Get) so the response carries the node's labels,
// loaded-model count, and in-flight total — the bare BackendNode keeps
// labels in a separate table, leaving the detail view's label list empty.
node, err := registry.GetWithExtras(ctx, id)
if err != nil {
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
}

View File

@@ -673,6 +673,49 @@ func (r *NodeRegistry) Get(ctx context.Context, nodeID string) (*BackendNode, er
return &node, nil
}
// GetWithExtras returns a single node enriched with the same computed fields as
// ListWithExtras (labels, loaded-model count, in-flight total). The plain Get
// returns a bare BackendNode whose Labels live in a separate table, so the node
// detail view needs this to show a node's existing labels and live counts.
func (r *NodeRegistry) GetWithExtras(ctx context.Context, nodeID string) (*NodeWithExtras, error) {
node, err := r.Get(ctx, nodeID)
if err != nil {
return nil, err
}
labels := make(map[string]string)
nodeLabels, err := r.GetNodeLabels(ctx, nodeID)
if err != nil {
xlog.Warn("GetWithExtras: failed to get labels", "node", nodeID, "error", err)
} else {
for _, l := range nodeLabels {
labels[l.Key] = l.Value
}
}
var modelCount int64
if err := r.db.WithContext(ctx).Model(&NodeModel{}).
Where("node_id = ? AND state = ?", nodeID, "loaded").
Count(&modelCount).Error; err != nil {
xlog.Warn("GetWithExtras: failed to get model count", "node", nodeID, "error", err)
}
var inFlight struct{ Total int }
if err := r.db.WithContext(ctx).Model(&NodeModel{}).
Select("COALESCE(SUM(in_flight), 0) as total").
Where("node_id = ? AND state IN ?", nodeID, []string{"loaded", "unloading"}).
Scan(&inFlight).Error; err != nil {
xlog.Warn("GetWithExtras: failed to get in-flight count", "node", nodeID, "error", err)
}
return &NodeWithExtras{
BackendNode: *node,
ModelCount: int(modelCount),
InFlightCount: inFlight.Total,
Labels: labels,
}, nil
}
// GetByName returns a single node by name.
func (r *NodeRegistry) GetByName(ctx context.Context, name string) (*BackendNode, error) {
var node BackendNode

View File

@@ -646,6 +646,38 @@ var _ = Describe("NodeRegistry", func() {
})
})
Describe("GetWithExtras", func() {
It("returns the node enriched with its labels map", func() {
node := makeNode("extras-node", "10.0.0.80:50051", 8_000_000_000)
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
Expect(registry.SetNodeLabel(context.Background(), node.ID, "env", "prod")).To(Succeed())
Expect(registry.SetNodeLabel(context.Background(), node.ID, "region", "us-east")).To(Succeed())
got, err := registry.GetWithExtras(context.Background(), node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got).ToNot(BeNil())
Expect(got.ID).To(Equal(node.ID))
Expect(got.Name).To(Equal("extras-node"))
Expect(got.Labels).To(Equal(map[string]string{"env": "prod", "region": "us-east"}))
})
It("returns an empty (non-nil) labels map when the node has none", func() {
node := makeNode("extras-no-labels", "10.0.0.81:50051", 8_000_000_000)
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
got, err := registry.GetWithExtras(context.Background(), node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got).ToNot(BeNil())
Expect(got.Labels).ToNot(BeNil())
Expect(got.Labels).To(BeEmpty())
})
It("returns an error for an unknown node", func() {
_, err := registry.GetWithExtras(context.Background(), "does-not-exist")
Expect(err).To(HaveOccurred())
})
})
Describe("FindNodesBySelector", func() {
It("returns nodes matching all labels in selector", func() {
n1 := makeNode("sel-match", "10.0.0.80:50051", 8_000_000_000)